diff --git a/.env.example b/.env.example index 067aad9cc6ea..79b2adaf0c8b 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 b4f0eef0c1e8..21fbce026f55 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,52 +61,6 @@ jobs: - name: Test run: vp run test - test_browser: - name: Test Browser - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - 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: blacksmith-12vcpu-macos-26 diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 77d3bff06e53..a16763cb141b 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,10 +2,12 @@ name: Mobile EAS Preview on: pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] jobs: preview: name: EAS Preview + if: contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1fb92163bfd..168c000c38ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,12 +180,46 @@ jobs: clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - id: relay_state + name: Read production relay tracing config + shell: bash + run: | + vp run --filter t3code-relay deploy \ + --stage prod \ + --read-state \ + --github-output \ + --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" + + - name: Upload relay client tracing config + uses: actions/upload-artifact@v7 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing.env + if-no-files-found: error + retention-days: 1 + - id: public_config name: Resolve production relay public config shell: bash @@ -272,6 +306,20 @@ jobs: cache: true run-install: true + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -377,6 +425,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 }} @@ -404,9 +455,21 @@ jobs: if [[ "${{ matrix.platform }}" == "mac" ]]; then if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS signing 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 signing enabled." args+=(--signed) else @@ -508,6 +571,20 @@ jobs: - --filter=@t3tools/web... - --filter=@t3tools/scripts... + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -670,6 +747,20 @@ jobs: - --filter=@t3tools/scripts... - --filter=@t3tools/web... + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -716,6 +807,9 @@ jobs: --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ + --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" 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 000000000000..afbdc55ba608 --- /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 339f79637022..bb52416cc779 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,13 +22,17 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", - "electron-updater": "^6.6.2" + "electron-store": "^8.2.0", + "electron-updater": "^6.6.2", + "playwright-core": "1.60.0", + "react-grab": "^0.1.32" }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "cross-env": "^10.1.0", "electron-builder": "26.8.1", + "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, "productName": "T3 Code (Alpha)" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs new file mode 100644 index 000000000000..a5dbdcfbe69c --- /dev/null +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -0,0 +1,40 @@ +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 = 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([ + 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( + Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]), +); +const compilerInput = [ + themeSource, + preflightSource, + annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), +].join("\n"); +const compiler = await compile(compilerInput, { base: appRoot }); +const css = compiler.build([...candidates]); +const encodedCss = `'${css + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n")}'`; +const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; + +await NodeFSP.writeFile(outputPath, moduleSource); diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 2a2e52449be9..c28d5ec358b6 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,8 +1,13 @@ -import { spawn, spawnSync } from "node:child_process"; -import { watch } from "node:fs"; -import { join } from "node:path"; - -import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; +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 { + desktopDir, + resolveDevProtocolClient, + resolveElectronLaunchCommand, +} from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -29,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); await waitForResources({ baseDir: desktopDir, @@ -53,19 +60,21 @@ const expectedExits = new WeakSet(); const watchers = []; function killChildTreeByPid(pid, signal) { - if (process.platform === "win32" || typeof pid !== "number") { + if (hostPlatform === "win32" || typeof pid !== "number") { return; } - spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); } function cleanupStaleDevApps() { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } - spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { + stdio: "ignore", + }); } function startApp() { @@ -79,7 +88,8 @@ function startApp() { const launchArgs = devProtocolClient ? electronArgs : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; - const app = spawn(resolveElectronPath(), launchArgs, { + const electronCommand = resolveElectronLaunchCommand(launchArgs); + const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -172,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)) { @@ -189,12 +199,14 @@ function startWatchers() { } function killChildTree(signal) { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } // 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 8f20001bbb08..69df02fb80d1 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,28 +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 { basename, 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 NodeModule from "node:module"; +import * as NodeOS from "node:os"; +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)"; @@ -30,29 +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 = 10; -const defaultIconPath = join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); - -function resolveDevelopmentProtocolCallbackPort() { - const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); - if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) { - return configuredPort + 1; - } - return 13774; -} +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 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; } @@ -63,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; } @@ -82,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; } @@ -96,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], @@ -106,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) { @@ -157,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]) { @@ -181,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; @@ -191,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`), ]); } @@ -204,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); @@ -221,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) { @@ -235,7 +231,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { ]; for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { - const infoPlistPath = join( + const infoPlistPath = NodePath.join( appBundlePath, "Contents", "Frameworks", @@ -243,7 +239,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { "Contents", "Info.plist", ); - if (!existsSync(infoPlistPath)) { + if (!NodeFS.existsSync(infoPlistPath)) { continue; } @@ -259,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) ) { @@ -294,42 +290,82 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } - rmSync(targetAppBundlePath, { recursive: true, force: true }); - cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: 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). + 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; } +function isLinuxSetuidSandboxConfigured(electronBinaryPath) { + if (hostPlatform !== "linux") { + return true; + } + + const sandboxPath = NodePath.join(NodePath.dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = NodeFS.statSync(sandboxPath); + return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + } catch { + return false; + } +} + +function resolveLinuxSandboxArgs(electronBinaryPath) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) { + return []; + } + + console.warn( + "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; +} + export function resolveElectronPath() { ensureElectronRuntime(); - const require = createRequire(import.meta.url); + const require = NodeModule.createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return electronBinaryPath; } return buildMacLauncher(electronBinaryPath); } +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} + export function resolveDevProtocolClient() { - if (process.platform !== "darwin" || !isDevelopment) { + if (hostPlatform !== "darwin" || !isDevelopment) { 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 2df47d3c62b3..c37838ab1836 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,13 +1,17 @@ -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; - -const require = createRequire(import.meta.url); +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 = NodeModule.createRequire(import.meta.url); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostArch = NodeOS.arch(); function getPlatformPath() { - switch (process.platform) { + switch (hostPlatform) { case "darwin": return "Electron.app/Contents/MacOS/Electron"; case "freebsd": @@ -17,32 +21,34 @@ function getPlatformPath() { case "win32": return "electron.exe"; default: - throw new Error(`Electron builds are not available on platform: ${process.platform}`); + throw new Error(`Electron builds are not available on platform: ${hostPlatform}`); } } function ensureExecutable(filePath) { - if (process.platform !== "win32") { - chmodSync(filePath, 0o755); + if (hostPlatform !== "win32") { + 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 (process.platform === "darwin") { + 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", @@ -58,11 +64,11 @@ function getRequiredRuntimePaths(electronDir, platformPath) { } function isMachO(filePath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return true; } - const result = spawnSync("file", ["-b", filePath], { + const result = NodeChildProcess.spawnSync("file", ["-b", filePath], { encoding: "utf8", }); @@ -71,18 +77,18 @@ function isMachO(filePath) { function missingRuntimePaths(electronDir, platformPath) { return getRequiredRuntimePaths(electronDir, platformPath).filter((runtimePath) => { - return !existsSync(runtimePath); + return !NodeFS.existsSync(runtimePath); }); } function invalidRuntimePaths(electronDir, platformPath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return []; } return [ - join(electronDir, "dist", platformPath), - join( + NodePath.join(electronDir, "dist", platformPath), + NodePath.join( electronDir, "dist", "Electron.app", @@ -91,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", }); @@ -110,45 +116,45 @@ function runChecked(command, args) { } function installElectronRuntime(electronDir, version) { - const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.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", [ "-fsSL", - `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`, + `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, ]); - if (process.platform === "darwin") { - runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); + if (hostPlatform === "darwin") { + 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 fdbe69b77800..fea5f0a120e5 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,15 +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 electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -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 child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs]); +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 375dbfe575f9..ecabd81fb407 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,11 +1,12 @@ -import { spawn } from "node:child_process"; +import * as NodeChildProcess from "node:child_process"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); +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 2b0a60c5d984..00455f4db725 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 052a25e4b97c..214fd383e042 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, }); @@ -176,7 +189,7 @@ const bootstrap = Effect.gen(function* () { ); } - yield* installDesktopIpcHandlers; + yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { @@ -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 000000000000..666c36d391de --- /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 eafdbf056dce..3c95b266bc18 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 52f4b12808e7..385e694338dd 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 000000000000..2eb55c72057f --- /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 a9c1d62e6852..95585acab74e 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 && process.platform === "darwin" && ext === "png") { + 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 000000000000..18bba9486cbe --- /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 000000000000..cad83229deb1 --- /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 000000000000..9b5ed56d1f34 --- /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 000000000000..0e283f8dd0c4 --- /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 002fd86b0a44..000000000000 --- 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 732de27b9ab0..000000000000 --- 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 3257edca8853..000000000000 --- 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 652072c1f5db..000000000000 --- 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 000000000000..7c7818994f63 --- /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 000000000000..5ec2edb595ff --- /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 000000000000..ae78080539b7 --- /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.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index ee732bf830cb..92da3f887ac3 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, false); assert.equal(environment.stateDir, "/tmp/t3/userdata"); assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 431e0d34d817..061a9368c536 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -11,10 +11,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"; @@ -30,54 +27,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 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"; @@ -135,9 +131,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; @@ -183,6 +179,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), + browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), @@ -206,7 +203,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, @@ -248,4 +245,4 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( }); export const layer = (input: MakeDesktopEnvironmentInput) => - Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); + Layer.effect(DesktopEnvironment, make(input)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index a7957ffca192..c5264332b661 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 2349fe52dc3a..21dd27ba28d5 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 000000000000..78b77b565b9c --- /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 f325c99d229f..cd2abe910654 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 96e56a87c9da..43e77a0c4cb1 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 5e4e034b5e77..d8bd1a13dcb0 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 6c5109c87140..3c0a513c9b51 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 07693a82707d..d92f62d16b7d 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 000000000000..cd54c46c89af --- /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 000000000000..e619b330d83e --- /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 000000000000..411af7553f91 --- /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 000000000000..43f634c44917 --- /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 e5fbb84c8adf..8b934fd8d85c 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 8b62323499e1..f04d2af7b1f6 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 50706923fb39..0b48adc308c3 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 f6ed5cb1df73..f3ce3b4b5f43 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 49b432fd5dde..0af8691f6c45 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 9be62e740b22..388b3fd2c150 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 74e6ae588482..be633971bea8 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 4dd8066e3c62..3dc218d82522 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 005c86e0868b..09fb5d1807db 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,8 +1,10 @@ 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"; @@ -22,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[] = []; @@ -79,11 +102,12 @@ 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.sync(ElectronMenu, () => { +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; let destructiveMenuIconCache: Option.Option | undefined; const getDestructiveMenuIcon = (): Option.Option => { - if (process.platform !== "darwin") { + if (platform !== "darwin") { return Option.none(); } if (destructiveMenuIconCache !== undefined) { @@ -95,6 +119,7 @@ export const layer = Layer.sync(ElectronMenu, () => { width: 12, height: 12, }); + icon.setTemplateImage(true); destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); } catch { destructiveMenuIconCache = Option.none(); @@ -140,16 +165,36 @@ export const layer = Layer.sync(ElectronMenu, () => { return ElectronMenu.of({ setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), + Effect.try({ + try: () => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }, + catch: (cause) => + new ElectronMenuOperationError({ + operation: "set-application-menu", + platform, + windowId: null, + itemCount: template.length, + cause, + }), + }).pipe(Effect.orDie), popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), + 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); @@ -167,21 +212,41 @@ export const layer = Layer.sync(ElectronMenu, () => { resume(Effect.succeed(selectedItemId)); }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); + try { + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + 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 2306c101c63a..56fe009fee22 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 a56e442ddcb6..757c26178d0d 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 c7b462658872..76162c1647a0 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 0ecce3bf70ec..316d3138bfa6 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 0ba7482aacef..4b81943eff2b 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 1e23d228504e..ef47e3d0954f 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 d2d3edd36962..8fcc34f41c24 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 7f3edf02aa85..435fbd002289 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 cc6c64842453..b59f8572739d 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 d41a8326e63b..dacb2eebb47d 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,47 +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"; -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; @@ -49,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, @@ -85,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 (process.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 000000000000..fc311877f829 --- /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 6d954a97aecd..e948571cc628 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 40f840548782..180e44e52d96 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,26 +33,28 @@ import { import { confirm, getAppBranding, + getLocalEnvironmentBearerToken, getLocalEnvironmentBootstrap, openExternal, pickFolder, setTheme, showContextMenu, } from "./methods/window.ts"; +import * as PreviewIpc from "./methods/preview.ts"; -export const installDesktopIpcHandlers = Effect.gen(function* () { +export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + yield* PreviewIpc.installPreviewEventForwarding(); 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); @@ -82,14 +75,12 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { 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); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); -}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); + for (const previewMethod of PreviewIpc.methods) { + yield* ipc.handle(previewMethod); + } +}); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1ded238c663c..cc2a92ca8fd9 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"; @@ -39,3 +33,38 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; +export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; +export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; +export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; +export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; +export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; +export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; +export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; +export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; +export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; +export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; +export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; +export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; +export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; +export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; +export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; +export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; +export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; +export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; +export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; +export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; +export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; +export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; +export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; +export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; +export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; +export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; +export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; +export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; +export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; +export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; +export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; +export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index 52b173266cdd..dd0625759e94 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 c5f1e2b2c90b..000000000000 --- 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 a5a7aacff797..000000000000 --- 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 000000000000..4e51496a6379 --- /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.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts new file mode 100644 index 000000000000..92336cc7362f --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -0,0 +1,54 @@ +import { it as effectIt } from "@effect/vitest"; +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 Schema from "effect/Schema"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import * as PreviewManager from "../../preview/Manager.ts"; +import * as PreviewIpc from "./preview.ts"; + +const { fromPartition } = vi.hoisted(() => ({ + fromPartition: vi.fn(() => { + throw new Error("Session can only be received when app is ready"); + }), +})); + +vi.mock("electron", () => ({ + BrowserWindow: { + getAllWindows: vi.fn(() => []), + }, + session: { + fromPartition, + }, + webContents: { + fromId: vi.fn(() => null), + }, +})); + +describe("preview IPC methods", () => { + beforeEach(() => { + fromPartition.mockClear(); + }); + + it("does not access the Electron session while the module loads", async () => { + await expect(import("./preview.ts")).resolves.toBeDefined(); + expect(fromPartition).not.toHaveBeenCalled(); + }); + + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => + Effect.map( + PreviewIpc.registerWebview + .handler({ tabId: "tab-1", webContentsId: 0 }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), + (exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); + expect(fromPartition).not.toHaveBeenCalled(); + }, + ), + ); +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts new file mode 100644 index 000000000000..2abf53ac2843 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -0,0 +1,370 @@ +import { + DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewArtifactInputSchema, + DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationEvaluateInputSchema, + DesktopPreviewAutomationPressInputSchema, + DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationTypeInputSchema, + DesktopPreviewAutomationWaitForInputSchema, + DesktopPreviewConfigInputSchema, + DesktopPreviewNavigateInputSchema, + DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingSaveInputSchema, + DesktopPreviewRegisterWebviewInputSchema, + DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewTabInputSchema, + DesktopPreviewWebviewConfigSchema, + PreviewAnnotationPayloadSchema, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +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 * 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) => + 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 = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab(tabId); + }), +}); + +export const closeTab = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.closeTab(tabId); + }), +}); + +export const registerWebview = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, + payload: DesktopPreviewRegisterWebviewInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.registerWebview(tabId, webContentsId); + }), +}); + +export const navigate = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, + payload: DesktopPreviewNavigateInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.navigate(tabId, url); + }), +}); + +const tabMethod = ( + channel: string, + name: string, + invoke: ( + manager: PreviewManager.PreviewManager["Service"], + tabId: string, + ) => Effect.Effect, +) => + DesktopIpc.makeIpcMethod({ + channel, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn(name)(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* invoke(manager, tabId); + }), + }); + +export const goBack = tabMethod( + IpcChannels.PREVIEW_GO_BACK_CHANNEL, + "desktop.ipc.preview.goBack", + (manager, tabId) => manager.goBack(tabId), +); +export const goForward = tabMethod( + IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, + "desktop.ipc.preview.goForward", + (manager, tabId) => manager.goForward(tabId), +); +export const refresh = tabMethod( + IpcChannels.PREVIEW_REFRESH_CHANNEL, + "desktop.ipc.preview.refresh", + (manager, tabId) => manager.refresh(tabId), +); +export const zoomIn = tabMethod( + IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, + "desktop.ipc.preview.zoomIn", + (manager, tabId) => manager.zoomIn(tabId), +); +export const zoomOut = tabMethod( + IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, + "desktop.ipc.preview.zoomOut", + (manager, tabId) => manager.zoomOut(tabId), +); +export const resetZoom = tabMethod( + IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, + "desktop.ipc.preview.resetZoom", + (manager, tabId) => manager.resetZoom(tabId), +); +export const hardReload = tabMethod( + IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, + "desktop.ipc.preview.hardReload", + (manager, tabId) => manager.hardReload(tabId), +); +export const openDevTools = tabMethod( + IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, + "desktop.ipc.preview.openDevTools", + (manager, tabId) => manager.openDevTools(tabId), +); +export const cancelPickElement = tabMethod( + IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, + "desktop.ipc.preview.cancelPickElement", + (manager, tabId) => manager.cancelPickElement(tabId), +); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); +export const stopRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, + "desktop.ipc.preview.stopRecording", + (manager, tabId) => manager.stopRecording(tabId), +); + +export const clearCookies = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCookies(); + }), +}); + +export const clearCache = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCache(); + }), +}); + +export const getPreviewConfig = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, + payload: DesktopPreviewConfigInputSchema, + result: DesktopPreviewWebviewConfigSchema, + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.getBrowserSession(environmentId); + return { + partition: yield* manager.getBrowserPartition(environmentId), + webPreferences: PREVIEW_WEBVIEW_PREFERENCES, + preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, + }; + }), +}); + +export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, + payload: DesktopPreviewAnnotationThemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAnnotationTheme(theme); + }), +}); + +export const pickElement = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.NullOr(PreviewAnnotationPayloadSchema), + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId); + }), +}); + +export const captureScreenshot = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: DesktopPreviewScreenshotArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.captureScreenshot(tabId); + }), +}); + +export const revealArtifact = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.revealArtifact(path); + }), +}); + +export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.copyArtifactToClipboard(path); + }), +}); + +export const automationStatus = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationStatus, + handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationStatus(tabId); + }), +}); + +export const automationSnapshot = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationSnapshot, + handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationSnapshot(tabId); + }), +}); + +export const automationClick = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, + payload: DesktopPreviewAutomationClickInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationClick(tabId, input); + }), +}); + +export const automationType = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, + payload: DesktopPreviewAutomationTypeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationType(tabId, input); + }), +}); + +export const automationPress = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, + payload: DesktopPreviewAutomationPressInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationPress(tabId, input); + }), +}); + +export const automationScroll = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, + payload: DesktopPreviewAutomationScrollInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationScroll(tabId, input); + }), +}); + +export const automationEvaluate = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, + payload: DesktopPreviewAutomationEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationEvaluate(tabId, input); + }), +}); + +export const automationWaitFor = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, + payload: DesktopPreviewAutomationWaitForInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationWaitFor(tabId, input); + }), +}); + +export const saveRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, + payload: DesktopPreviewRecordingSaveInputSchema, + result: DesktopPreviewRecordingArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.saveRecording(tabId, mimeType, data); + }), +}); + +export const methods = [ + createTab, + closeTab, + registerWebview, + navigate, + goBack, + goForward, + refresh, + zoomIn, + zoomOut, + resetZoom, + hardReload, + openDevTools, + clearCookies, + clearCache, + getPreviewConfig, + setAnnotationTheme, + pickElement, + cancelPickElement, + captureScreenshot, + revealArtifact, + copyArtifactToClipboard, + automationStatus, + automationSnapshot, + automationClick, + automationType, + automationPress, + automationScroll, + automationEvaluate, + automationWaitFor, + startRecording, + stopRecording, + saveRecording, +] as const; diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts deleted file mode 100644 index bc5e4a9aeb27..000000000000 --- 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 cd0f215e1938..9a9ce768973b 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 6eeaa3202d91..9c9af2a4e2b9 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 45ea8502121d..b2212609030d 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 1cb4d7265a1c..3cb705d03611 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 9356eef441b6..b88eb18e57f9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,31 +9,34 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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"; @@ -44,6 +47,8 @@ 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 BrowserSession from "./preview/BrowserSession.ts"; +import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( @@ -51,19 +56,21 @@ const desktopEnvironmentLayer = Layer.unwrap( const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( Effect.flatMap((app) => app.metadata), ); + const platform = yield* HostProcessPlatform; + const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), - platform: process.platform, - processArch: process.arch, + platform, + processArch, ...metadata, }); }), ); 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) { @@ -99,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)); @@ -123,11 +129,19 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), ); -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); +const desktopPreviewLayer = PreviewManager.layer.pipe( + Layer.provideMerge(BrowserSession.layer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe( + Layer.provideMerge(desktopServerExposureLayer), + Layer.provideMerge(desktopPreviewLayer), +); const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), @@ -135,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 84f7580cb07e..6f126f413346 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,8 +1,16 @@ -import type { DesktopBridge } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopPreviewPointerEvent, + 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" && @@ -34,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( @@ -96,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; @@ -141,4 +128,97 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + preview: { + createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), + closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), + registerWebview: (tabId, webContentsId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }), + navigate: (tabId, url) => + ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }), + goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), + goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), + refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), + zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), + zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), + resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), + hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + openDevTools: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), + clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), + clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), + getPreviewConfig: (environmentId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + setAnnotationTheme: (theme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), + pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + cancelPickElement: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), + captureScreenshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + revealArtifact: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), + copyArtifactToClipboard: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), + recording: { + startScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), + stopScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), + save: (tabId, mimeType, data) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { + tabId, + mimeType, + data, + }), + onFrame: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + }, + }, + automation: { + status: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), + snapshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), + click: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), + type: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), + press: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), + scroll: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), + evaluate: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), + waitFor: (tabId, input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), + }, + onStateChange: (listener) => { + const wrappedListener = ( + _event: Electron.IpcRendererEvent, + tabId: unknown, + state: unknown, + ) => { + if (typeof tabId !== "string" || typeof state !== "object" || state === null) return; + listener(tabId, state as DesktopPreviewTabState); + }; + ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); + }, + onPointerEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { + if (typeof pointerEvent !== "object" || pointerEvent === null) return; + listener(pointerEvent as DesktopPreviewPointerEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + }, + }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts new file mode 100644 index 000000000000..84e6abb29ee6 --- /dev/null +++ b/apps/desktop/src/preview-pick-preload.ts @@ -0,0 +1 @@ +import "./preview/PickPreload.ts"; diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css new file mode 100644 index 000000000000..89676a22d585 --- /dev/null +++ b/apps/desktop/src/preview/Annotation.css @@ -0,0 +1,68 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--t3-font-sans); + --font-mono: var(--t3-font-mono); + --color-background: var(--t3-background); + --color-foreground: var(--t3-foreground); + --color-popover: var(--t3-popover); + --color-popover-foreground: var(--t3-popover-foreground); + --color-primary: var(--t3-primary); + --color-primary-foreground: var(--t3-primary-foreground); + --color-muted: var(--t3-muted); + --color-muted-foreground: var(--t3-muted-foreground); + --color-accent: var(--t3-accent); + --color-accent-foreground: var(--t3-accent-foreground); + --color-border: var(--t3-border); + --color-input: var(--t3-input); + --color-ring: var(--t3-ring); + --radius-sm: calc(var(--t3-radius) - 4px); + --radius-md: calc(var(--t3-radius) - 2px); + --radius-lg: var(--t3-radius); + --radius-xl: calc(var(--t3-radius) + 4px); + --radius-2xl: calc(var(--t3-radius) + 8px); +} + +:host { + --t3-font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --t3-font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + --t3-radius: 0.625rem; + --t3-background: white; + --t3-foreground: oklch(0.269 0 0); + --t3-popover: white; + --t3-popover-foreground: oklch(0.269 0 0); + --t3-primary: oklch(0.488 0.217 264); + --t3-primary-foreground: white; + --t3-muted: rgb(0 0 0 / 4%); + --t3-muted-foreground: oklch(0.556 0 0); + --t3-accent: rgb(0 0 0 / 4%); + --t3-accent-foreground: oklch(0.269 0 0); + --t3-border: rgb(0 0 0 / 8%); + --t3-input: rgb(0 0 0 / 10%); + --t3-ring: oklch(0.488 0.217 264); + color: var(--t3-foreground); + font-family: var(--t3-font-sans); +} + +* { + box-sizing: border-box; + border-color: var(--t3-border); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); + outline-offset: 1px; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts new file mode 100644 index 000000000000..5b6b73c8ba78 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. +export const previewAnnotationStyles = + '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts new file mode 100644 index 000000000000..e258bb2dfc5a --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -0,0 +1,191 @@ +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(() => ({ + fromPartition: vi.fn(), + sessions: new Map< + string, + { + readonly clearCache: ReturnType; + readonly clearStorageData: ReturnType; + readonly getUserAgent: ReturnType; + readonly setPermissionRequestHandler: ReturnType; + readonly setUserAgent: ReturnType; + } + >(), +})); + +vi.mock("electron", () => ({ + session: { + fromPartition, + }, +})); + +import * as BrowserSession from "./BrowserSession.ts"; + +const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); + +describe("BrowserSession", () => { + beforeEach(() => { + sessions.clear(); + fromPartition.mockReset(); + fromPartition.mockImplementation((partition: string) => { + const browserSession = { + clearCache: vi.fn(() => Promise.resolve()), + clearStorageData: vi.fn(() => Promise.resolve()), + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + }; + sessions.set(partition, browserSession); + return browserSession; + }); + }); + + it.effect("derives deterministic partitions and memoizes sessions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + const partition = yield* browserSessions.getPartition("scope-a"); + const first = yield* browserSessions.getSession("scope-a"); + const second = yield* browserSessions.getSession("scope-a"); + + assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969"); + assert.strictEqual(first, second); + assert.strictEqual(fromPartition.mock.calls.length, 1); + }).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; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + + yield* browserSessions.clearCookies(); + yield* browserSessions.clearCache(); + + assert.strictEqual(sessions.size, 2); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ + { + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }, + ]); + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).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 new file mode 100644 index 000000000000..afa8dafe976f --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -0,0 +1,182 @@ +import type { Session } from "electron"; +import { session } from "electron"; +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 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 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 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 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); + +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 BrowserSessionPartitionDerivationError({ + scope, + cause, + }), + ), + ); + return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; + }); + + const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { + const partition = yield* getPartition(scope); + return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { + const existing = sessions.get(partition); + if (existing) return Effect.succeed([existing, sessions] as const); + return Effect.try({ + try: () => { + const browserSession = session.fromPartition(partition); + const userAgent = browserSession + .getUserAgent() + .replace(/Electron\/[\d.]+ /, "") + .replace(/\s*t3code\/[\d.]+/, ""); + browserSession.setUserAgent(userAgent); + browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; + callback(allowed.includes(permission)); + }); + const next = new Map(sessions); + next.set(partition, browserSession); + return [browserSession, next] as const; + }, + catch: (cause) => + new BrowserSessionCreationError({ + scope, + partition, + cause, + }), + }); + }); + }); + + return BrowserSession.of({ + getPartition, + isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + getSession, + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.entries()].map(([partition, browserSession]) => + Effect.tryPromise({ + try: () => + browserSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + catch: (cause) => + new BrowserSessionStorageClearError({ + partition, + cause, + }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + const sessions = yield* SynchronizedRef.get(sessionsRef); + yield* Effect.all( + [...sessions.entries()].map(([partition, browserSession]) => + Effect.tryPromise({ + try: () => browserSession.clearCache(), + catch: (cause) => + new BrowserSessionCacheClearError({ + partition, + cause, + }), + }), + ), + { concurrency: "unbounded", discard: true }, + ); + }), + }); +}).pipe(Effect.withSpan("BrowserSession.make")); + +export const layer = Layer.effect(BrowserSession, make); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts new file mode 100644 index 000000000000..00616c6a4761 --- /dev/null +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -0,0 +1,6 @@ +export const START_PICK_CHANNEL = "preview:start-pick"; +export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; +export const HUMAN_INPUT_CHANNEL = "preview:human-input"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts new file mode 100644 index 000000000000..acb0d783a82d --- /dev/null +++ b/apps/desktop/src/preview/Manager.test.ts @@ -0,0 +1,742 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +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, 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((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), + fromId: vi.fn(() => null), + mkdir: vi.fn((_path: string) => undefined), + showItemInFolder: vi.fn(), + webviewSend: vi.fn(), + writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), + writeImage: vi.fn(), + })); + +vi.mock("electron", () => ({ + clipboard: { + writeImage, + }, + nativeImage: { + createFromPath, + }, + shell: { + showItemInFolder, + }, + session: { + fromPartition: vi.fn(), + }, + webContents: { + fromId, + }, +})); + +const browserSessionLayer = Layer.succeed( + BrowserSession.BrowserSession, + BrowserSession.BrowserSession.of({ + getPartition: () => Effect.succeed("persist:t3code-preview-test"), + isPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getSession: () => Effect.die("unexpected getSession"), + clearCookies: () => Effect.void, + clearCache: () => Effect.void, + }), +); + +const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", + } as DesktopEnvironment.DesktopEnvironment["Service"]), +); + +const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + mkdir(path); + }), + writeFile: (path, data) => + Effect.sync(() => { + writeFile(path, data); + }), +}); + +const layer = PreviewManager.layer.pipe( + Layer.provideMerge(browserSessionLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(fileSystemLayer), + Layer.provideMerge(Path.layer), +); +const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); + +const withManager = ( + use: ( + manager: PreviewManager.PreviewManager["Service"], + ) => Effect.Effect, +) => + Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + return yield* use(manager); + }).pipe(Effect.provide(layer), Effect.scoped); + +describe("PreviewManager", () => { + beforeEach(() => { + fromId.mockClear(); + mkdir.mockClear(); + writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); + }); + + effectIt.effect("reports an unregistered webview as temporarily unavailable", () => + withManager((manager) => + Effect.gen(function* () { + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + + yield* manager.createTab("tab_1"); + + expect(yield* manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); + }), + ), + ); + + 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* () { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com:8443/path?query=value", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + 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(), + }, + capturePage, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + + const artifact = yield* manager.captureScreenshot("tab_1"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + 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, + }); + }), + ), + ); + + effectIt.effect("keeps element picking active during subframe navigation", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + once: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: 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.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + listeners.get("did-start-navigation")?.({}, "about:blank", false, false); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + listeners.get("did-start-navigation")?.({}, "https://example.com/next", false, true); + expect(yield* Fiber.join(pick)).toBeNull(); + }), + ), + ); + + effectIt.effect("reveals only files inside the configured browser artifact directory", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewArtifactPathOutsideDirectoryError", + artifactPath: "/tmp/t3/dev/settings.json", + artifactDirectory: "/tmp/t3/dev/browser-artifacts", + }); + expect("cause" in error).toBe(false); + }), + ), + ); + + effectIt.effect("copies screenshot artifacts to the system clipboard", () => + withManager((manager) => + Effect.gen(function* () { + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + + yield* manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + const exit = yield* Effect.exit( + manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + 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, + }); + }), + ), + ); + + effectIt.effect("emits the resolved pointer target before dispatching an automation click", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return 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((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + 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.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }), + ), + ); + + effectIt.effect("still interrupts agent control for a different human pointer event", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return 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((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + 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 click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + const exit = yield* Fiber.await(click); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + 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 new file mode 100644 index 000000000000..6fd65cd25b5b --- /dev/null +++ b/apps/desktop/src/preview/Manager.ts @@ -0,0 +1,2811 @@ +/** + * Desktop side of the in-app browser preview. + * + * Hosts per-tab Chromium WebContents references (the actual + * elements live in the renderer; we only attach listeners and forward state + * here). Single layer-scoped browser session partition. + */ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, + PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, + PreviewAutomationEvaluateInput, + PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + shell, + webContents, +} from "electron"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; +import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; + +export type PreviewNavStatus = + | { kind: "Idle" } + | { kind: "Loading"; url: string; title: string } + | { kind: "Success"; url: string; title: string } + | { + kind: "LoadFailed"; + url: string; + title: string; + code: number; + description: string; + }; + +export interface PreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: PreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + zoomFactor: number; + controller: "human" | "agent" | "none"; + updatedAt: string; +} + +/** Discrete zoom levels mirroring Chrome's preset list. */ +const ZOOM_LEVELS: ReadonlyArray = [ + 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, +]; + +const DEFAULT_ZOOM_FACTOR = 1.0; +const ZOOM_EPSILON = 0.001; +const MAX_EVALUATION_BYTES = 64_000; +const MAX_VISIBLE_TEXT_LENGTH = 20_000; +const MAX_INTERACTIVE_ELEMENTS = 200; +const MAX_SCREENSHOT_WIDTH = 1280; +const DIAGNOSTIC_BUFFER_LIMIT = 200; +const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; +const AGENT_CURSOR_MOVE_MS = 160; +const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { + colorScheme: "light", + radius: "0.625rem", + background: "white", + foreground: "oklch(0.269 0 0)", + popover: "white", + popoverForeground: "oklch(0.269 0 0)", + primary: "oklch(0.488 0.217 264)", + primaryForeground: "white", + muted: "rgb(0 0 0 / 4%)", + mutedForeground: "oklch(0.556 0 0)", + accent: "rgb(0 0 0 / 4%)", + accentForeground: "oklch(0.269 0 0)", + border: "rgb(0 0 0 / 8%)", + input: "rgb(0 0 0 / 10%)", + ring: "oklch(0.488 0.217 264)", + fontSans: "system-ui, sans-serif", + fontMono: "ui-monospace, monospace", +}; + +const artifactSiteSlug = (rawUrl: string): string => { + try { + const url = new URL(rawUrl); + const slug = url.hostname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) + .replace(/-+$/g, ""); + return slug || "site"; + } catch { + return "site"; + } +}; + +interface CdpEvaluationResult { + readonly result?: { + readonly value?: unknown; + readonly description?: string; + }; + readonly exceptionDetails?: { + readonly text?: string; + readonly exception?: { readonly description?: string }; + }; +} + +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; + const x = rect["x"]; + const y = rect["y"]; + const width = rect["width"]; + const height = rect["height"]; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + return { + x: Math.max(0, Math.floor(x)), + y: Math.max(0, Math.floor(y)), + width: Math.max(1, Math.ceil(width)), + height: Math.max(1, Math.ceil(height)), + }; +}; + +const captureAnnotationScreenshot = ( + tabId: string, + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +): Effect.Effect => + Effect.tryPromise({ + try: () => + wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ), + catch: (cause) => + new PreviewOperationError({ + operation: "captureAnnotationScreenshot", + tabId, + webContentsId: wc.id, + cause, + }), + }).pipe( + Effect.map((image) => { + const size = image.getSize(); + return { + dataUrl: image.toDataURL(), + width: size.width, + height: size.height, + cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, + }; + }), + ); + +const findZoomStep = (current: number): number => { + const index = ZOOM_LEVELS.findIndex( + (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, + ); + if (index < 0) return ZOOM_LEVELS.length - 1; + return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; +}; + +const nextZoomLevel = (current: number, direction: "in" | "out"): number => { + const step = findZoomStep(current); + if (direction === "in") { + return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; + } + return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; +}; + +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 } + | { readonly kind: "key"; readonly key: string; readonly code: string }; + +interface ManagedListeners { + readonly scope: Scope.Closeable; +} + +interface PickSession { + readonly cancel: Effect.Effect; +} + +interface BrowserControlSession { + readonly webContentsId: number; + readonly semaphore: Semaphore.Semaphore; + readonly scope: Scope.Closeable; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record, + ) => void; +} + +interface BrowserDiagnostics { + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly requests: ReadonlyMap; +} + +type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; + +interface ExpectedAgentInput { + readonly signal: PreviewInputSignal; + readonly expiresAt: number; +} + +const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ + key: string; + meta: boolean; + shift: boolean; + control: boolean; +}> = Object.freeze([ + // mod+shift+J → preview.toggle + { key: "j", meta: true, shift: true, control: false }, + // mod+K → command palette + { key: "k", meta: true, shift: false, control: false }, + // mod+, → settings (macOS convention) + { key: ",", meta: true, shift: false, control: false }, + // mod+W → close tab/panel + { key: "w", meta: true, shift: false, control: false }, +]); + +const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { + if (typeof value !== "object" || value === null || !("kind" in value)) return false; + if (value.kind === "pointer") { + return ( + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "button" in value && + typeof value.button === "number" + ); + } + return ( + value.kind === "key" && + "key" in value && + typeof value.key === "string" && + "code" in value && + typeof value.code === "string" + ); +}; + +const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { + if (left.kind !== right.kind) return false; + if (left.kind === "pointer" && right.kind === "pointer") { + return ( + Math.abs(left.x - right.x) <= 1 && + Math.abs(left.y - right.y) <= 1 && + left.button === right.button + ); + } + return ( + left.kind === "key" && + right.kind === "key" && + left.key === right.key && + left.code === right.code + ); +}; + +const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( + artifactDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentScope = yield* Scope.Scope; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const resolvedArtifactDirectory = path.resolve(artifactDirectory); + const playwrightInstallExpression = yield* Effect.cached( + playwrightInjectedRuntimeInstallExpression(), + ); + + const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); + const mainWindowRef = yield* Ref.make>(Option.none()); + const tabsRef = yield* SynchronizedRef.make>(new Map()); + const attachedRef = yield* Ref.make>(new Map()); + const listenersRef = yield* Ref.make>(new Set()); + const pointerEventListenersRef = yield* Ref.make>(new Set()); + const recordingFrameListenersRef = yield* Ref.make>( + new Set(), + ); + const pickSessionsRef = yield* Ref.make>(new Map()); + const controlSessionsRef = yield* SynchronizedRef.make< + ReadonlyMap + >(new Map()); + const diagnosticsRef = yield* Ref.make>(new Map()); + const expectedAgentInputsRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const controlEpochRef = yield* Ref.make>(new Map()); + const actionTimelineRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const actionSequenceRef = yield* Ref.make(0); + const pointerSequenceRef = yield* Ref.make(0); + const recordingTabIdRef = yield* Ref.make>(Option.none()); + + 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 = (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 = ( + source: ReadonlyMap, + update: (copy: Map) => void, + ): ReadonlyMap => { + const copy = new Map(source); + update(copy); + 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) => deliverEvent("state-change", tabId, () => listener(tabId, state)), + { discard: true }, + ); + }); + + const update = Effect.fn("PreviewManager.update")(function* ( + tabId: string, + patch: Partial, + ) { + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) return [Option.none(), tabs] as const; + const state: PreviewTabState = { ...current, ...patch, updatedAt }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emit(tabId, next.value); + }); + + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( + tabId: string, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + const tab = tabs.get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (tab.webContentsId == null) { + return yield* new PreviewWebviewNotInitializedError({ tabId }); + } + const wc = webContents.fromId(tab.webContentsId); + if (!wc) { + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: tab.webContentsId, + }); + } + return wc; + }); + + const resolveArtifactPath = (artifactPath: string) => + attempt({ operation: "resolveArtifactPath", artifactPath }, () => { + const resolvedPath = path.resolve(artifactPath); + const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return null; + } + return resolvedPath; + }).pipe( + Effect.flatMap((resolvedPath) => + resolvedPath === null + ? Effect.fail( + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), + ) + : Effect.succeed(resolvedPath), + ), + ); + + const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( + webContentsId: number, + ) { + const tabs = yield* SynchronizedRef.get(tabsRef); + return ( + Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null + ); + }); + + const pushBounded = (buffer: ReadonlyArray, entry: A): ReadonlyArray => + [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); + + const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* ( + webContentsId: number, + method: string, + params: Record, + ) { + const timestamp = yield* currentIso; + yield* Ref.update(diagnosticsRef, (allDiagnostics) => { + const current = allDiagnostics.get(webContentsId); + if (!current) return allDiagnostics; + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + const next = (() => { + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }), + }; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }), + }; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record) + : {}; + return { + ...current, + consoleEntries: pushBounded(current.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }), + }; + } + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record) + : {}; + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + }), + }; + } + if (method === "Network.responseReceived" && requestId) { + const request = current.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + return request && status !== null && status >= 400 + ? { + ...current, + networkEntries: pushBounded(current.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }), + } + : current; + } + if (method === "Network.loadingFailed" && requestId) { + const request = current.requests.get(requestId); + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + networkEntries: request + ? pushBounded(current.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }) + : current.networkEntries, + }; + } + if (method === "Network.loadingFinished" && requestId) { + return { + ...current, + requests: replaceMap(current.requests, (copy) => { + copy.delete(requestId); + }), + }; + } + return current; + })(); + return replaceMap(allDiagnostics, (copy) => { + copy.set(webContentsId, next); + }); + }); + }); + + const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( + webContentsId: number, + ) { + const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ + sessions.get(webContentsId), + replaceMap(sessions, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (control) { + yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); + return; + } + yield* Ref.update(diagnosticsRef, (diagnostics) => + replaceMap(diagnostics, (copy) => { + copy.delete(webContentsId); + }), + ); + }); + + const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( + wc: Electron.WebContents, + ) { + 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, + }), + ); + } + 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 }, + ), + ); + 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 createControlSession(); + }, + ); + }); + + const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => + replaceMap(timelines, (copy) => { + copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200)); + }), + ); + const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) => + Ref.update(actionTimelineRef, (timelines) => { + const timeline = timelines.get(tabId); + if (!timeline) return timelines; + return replaceMap(timelines, (copy) => { + copy.set( + tabId, + timeline.map((candidate) => (candidate.id === event.id ? event : candidate)), + ); + }); + }); + + type SendCommand = ( + method: string, + commandParams?: Record, + ) => Effect.Effect; + + const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* ( + tabId: string, + wc: Electron.WebContents, + action: string, + use: (send: SendCommand) => Effect.Effect, + ) { + const sequence = yield* nextCounter(actionSequenceRef); + const startedAt = yield* currentIso; + const millis = yield* currentMillis; + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`, + action, + status: "running", + startedAt, + }; + yield* pushAction(tabId, actionEvent); + const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + const control = yield* ensureControlSession(wc); + const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { + yield* update(tabId, { controller: "agent" }); + const send: SendCommand = Effect.fn("PreviewManager.sendCommand")( + function* (method, commandParams) { + const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; + if (before !== epoch) { + return yield* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); + } + 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* new PreviewAutomationControlInterruptedError({ + operation: action, + tabId, + webContentsId: wc.id, + }); + } + return result; + }, + ); + return yield* use(send); + }); + const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( + exit: Exit.Exit, + ) { + const completedAt = yield* currentIso; + if (exit._tag === "Success") { + yield* replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt, + }); + } else { + const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); + 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: errorMessage, + }); + } + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.has(tabId)) yield* update(tabId, { controller: "none" }); + }); + return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize))); + }); + + const evaluateWithDebugger = ( + tabId: string, + send: SendCommand, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Effect.Effect => + send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + }).pipe( + Effect.flatMap((rawResponse) => { + const response = rawResponse as CdpEvaluationResult; + 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, + }), + ); + }), + ); + + const automationLocator = (input: { + readonly selector?: string | undefined; + 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.pipe( + Effect.mapError( + (cause) => + new PreviewOperationError({ + operation: "ensurePlaywrightInjected", + tabId, + cause, + }), + ), + ); + yield* evaluateWithDebugger(tabId, send, expression, true); + }); + + const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( + tabId: string, + ) { + const session = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (session) yield* session.cancel; + }); + + const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( + webContentsId: number, + ) { + const managed = yield* Ref.modify(attachedRef, (attached) => [ + attached.get(webContentsId), + replaceMap(attached, (copy) => { + copy.delete(webContentsId); + }), + ]); + if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); + }); + + const isAppShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + APP_FORWARDED_SHORTCUTS.some( + (shortcut) => + shortcut.key.toLowerCase() === input.key.toLowerCase() && + shortcut.meta === input.meta && + shortcut.shift === input.shift && + shortcut.control === input.control, + ); + + const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { + const url = wc.getURL(); + const title = wc.getTitle(); + if (url === "" || url === "about:blank") return { kind: "Idle" }; + if (wc.isLoading()) return { kind: "Loading", url, title }; + return { kind: "Success", url, title }; + }; + + const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")( + function* (tabId: string, signal: PreviewInputSignal) { + const now = yield* currentMillis; + return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + const matched = index >= 0; + const nextPending = matched + ? pending.filter((_, pendingIndex) => pendingIndex !== index) + : pending; + return [ + matched, + replaceMap(allExpected, (copy) => { + if (nextPending.length === 0) copy.delete(tabId); + else copy.set(tabId, nextPending); + }), + ] as const; + }); + }, + ); + + const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* ( + tabId: string, + signal: PreviewInputSignal, + ) { + const now = yield* currentMillis; + yield* Ref.update(expectedAgentInputsRef, (allExpected) => + replaceMap(allExpected, (copy) => { + const pending = (allExpected.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]); + }), + ); + }); + + const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + const scope = yield* Scope.fork(parentScope, "sequential"); + const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { + if (wc.isDestroyed()) return; + yield* update(tabId, { + navStatus: computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + }); + }); + const sync = () => runFork(syncState()); + const failed = (_event: Event, code: number, description: string): void => { + if (code === -3) return; + runFork( + update(tabId, { + navStatus: { + kind: "LoadFailed", + url: wc.getURL(), + title: wc.getTitle(), + code, + description, + }, + }), + ); + }; + const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( + rawSignal?: unknown, + ) { + if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { + return; + } + yield* Ref.update(controlEpochRef, (epochs) => + replaceMap(epochs, (copy) => { + copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); + }), + ); + yield* update(tabId, { controller: "human" }); + yield* Effect.sleep(750); + const tabs = yield* SynchronizedRef.get(tabsRef); + if (tabs.get(tabId)?.controller === "human") { + yield* update(tabId, { controller: "none" }); + } + }); + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + runFork(handleHumanInput(rawSignal)); + }; + const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( + event: Electron.Event, + input: Electron.Input, + ) { + const mainWindow = yield* Ref.get(mainWindowRef); + if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + return; + } + event.preventDefault(); + mainWindow.value.webContents.sendInputEvent({ + type: "keyDown", + keyCode: input.key, + modifiers: [ + ...(input.meta ? (["meta"] as const) : []), + ...(input.shift ? (["shift"] as const) : []), + ...(input.control ? (["control"] as const) : []), + ...(input.alt ? (["alt"] as const) : []), + ], + }); + }); + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + runFork(forwardShortcut(event, input)); + }; + yield* Scope.addFinalizer( + scope, + 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); + wc.off("did-start-loading", sync); + wc.off("did-stop-loading", sync); + wc.off("did-fail-load", failed as never); + wc.off("before-input-event", beforeInput); + wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + }).pipe(Effect.ignore), + ); + const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { + 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); + wc.on("did-start-loading", sync); + wc.on("did-stop-loading", sync); + wc.on("did-fail-load", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.setWindowOpenHandler(({ url }) => { + runFork( + attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ).pipe(Effect.ignore), + ); + return { action: "deny" }; + }); + wc.on("before-input-event", beforeInput); + }); + yield* Ref.update(attachedRef, (attached) => + replaceMap(attached, (copy) => { + copy.set(wc.id, { scope }); + }), + ); + }); + yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); + }); + + const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( + window: BrowserWindow, + ) { + yield* Ref.set(mainWindowRef, Option.some(window)); + }); + + const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { + const updatedAt = yield* currentIso; + const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const existing = tabs.get(tabId); + if (existing) return [existing, tabs] as const; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + return [ + initial, + replaceMap(tabs, (copy) => { + copy.set(tabId, initial); + }), + ] as const; + }); + yield* emit(tabId, state); + return state; + }); + + const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return; + yield* cancelPickElement(tabId); + if (tab.webContentsId != null) { + yield* Effect.all( + [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], + { concurrency: 2, discard: true }, + ); + } + const updatedAt = yield* currentIso; + const closed: PreviewTabState = { + ...tab, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt, + }; + yield* SynchronizedRef.update(tabsRef, (tabs) => + replaceMap(tabs, (copy) => { + copy.delete(tabId); + }), + ); + yield* emit(tabId, closed); + }); + + const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( + tabId: string, + webContentsId: number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const wc = webContents.fromId(webContentsId); + const mainWindow = yield* Ref.get(mainWindowRef); + if ( + !wc || + wc.getType() !== "webview" || + (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) + ) { + 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({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => + wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), + ); + return; + } + if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + yield* Effect.all( + [ + detachControlSession(tab.webContentsId), + detachListeners(tab.webContentsId), + cancelPickElement(tabId), + ], + { concurrency: 3, discard: true }, + ); + } + yield* attachListeners(tabId, wc); + runFork(ensureControlSession(wc).pipe(Effect.ignore)); + 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; + }); + 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 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({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => + wc.reload(), + ); + return; + } + yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => + wc.loadURL(url), + ); + }); + + const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( + operation: string, + tabId: string, + use: (wc: Electron.WebContents) => void, + ) { + const wc = yield* requireWebContents(tabId); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); + }); + + const goBack = (tabId: string) => + withWebContents("goBack", tabId, (wc) => { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + }); + const goForward = (tabId: string) => + withWebContents("goForward", tabId, (wc) => { + if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); + }); + const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const hardReload = (tabId: string) => + withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + + const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + if (wc.isDevToolsOpened()) { + yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () => + wc.devToolsWebContents?.focus(), + ); + return; + } + yield* detachControlSession(wc.id); + yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + }); + wc.openDevTools({ mode: "detach" }); + }); + }); + + const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* ( + theme: DesktopPreviewAnnotationTheme, + ) { + yield* Ref.set(annotationThemeRef, theme); + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach( + tabs.values(), + (tab) => { + if (tab.webContentsId == null) return Effect.void; + const wc = webContents.fromId(tab.webContentsId); + return !wc || wc.isDestroyed() + ? Effect.void + : attempt( + { + operation: "setAnnotationTheme", + tabId: tab.tabId, + webContentsId: tab.webContentsId, + }, + () => wc.send(ANNOTATION_THEME_CHANNEL, theme), + ).pipe(Effect.ignore); + }, + { discard: true }, + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + const wc = yield* requireWebContents(tabId); + yield* cancelPickElement(tabId); + const annotationTheme = yield* Ref.get(annotationThemeRef); + return yield* Effect.callback( + (resume) => { + const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { + 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); + }).pipe(Effect.ignore); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.delete(tabId); + }), + ); + }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationPayload | null, + ) { + const active = (yield* Ref.get(pickSessionsRef)).get(tabId); + if (!active || active.cancel !== cancel) return; + yield* cleanup(); + resume(Effect.succeed(payload)); + }); + const settle = (payload: PreviewAnnotationPayload | null) => { + runFork(settlePick(payload)); + }; + const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + yield* cleanup(); + const tabs = yield* SynchronizedRef.get(tabsRef); + const activeTab = tabs.get(tabId); + if (activeTab?.webContentsId != null) { + const activeWc = webContents.fromId(activeTab.webContentsId); + if (activeWc && !activeWc.isDestroyed()) { + yield* attempt( + { + operation: "cancelPickElement", + tabId, + webContentsId: activeWc.id, + }, + () => activeWc.send(CANCEL_PICK_CHANNEL), + ).pipe(Effect.ignore); + } + } + resume(Effect.succeed(null)); + }); + const cancel = cancelPickSession(); + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (!isPreviewAnnotationPayload(payload)) { + settle(null); + return; + } + const cropRect = normalizeCaptureRect(args[1]); + runFork( + captureAnnotationScreenshot(tabId, wc, cropRect).pipe( + Effect.matchEffect({ + onFailure: () => Effect.sync(() => settle(payload)), + onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + }), + Effect.ensuring( + attempt( + { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, + () => { + if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); + }, + ).pipe(Effect.ignore), + ), + ), + ); + }; + const onDestroyed = () => settle(null); + const onNavigated = ( + _event: Electron.Event, + _url: string, + _isInPlace: boolean, + isMainFrame: boolean, + ) => { + if (isMainFrame) settle(null); + }; + const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + 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); + if (!wc.isFocused()) wc.focus(); + wc.send(START_PICK_CHANNEL, annotationTheme); + }); + yield* Ref.update(pickSessionsRef, (sessions) => + replaceMap(sessions, (copy) => { + copy.set(tabId, { cancel }); + }), + ); + }); + runFork( + registerPickElement().pipe( + Effect.catch((error: PreviewManagerError) => { + resume(Effect.fail(error)); + return cleanup(); + }), + ), + ); + return cancel; + }, + ); + }); + + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( + tabId: string, + transform: (current: number) => number, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) return; + const next = transform(tab.zoomFactor); + if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; + if (tab.webContentsId != null) { + const wc = webContents.fromId(tab.webContentsId); + if (wc && !wc.isDestroyed()) { + yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(next), + ); + } + } + yield* update(tabId, { zoomFactor: next }); + }); + + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + const [createdAt, millis, image] = yield* Effect.all([ + currentIso, + currentMillis, + 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) => + 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, + path: artifactPath, + mimeType: "image/png" as const, + sizeBytes: data.byteLength, + createdAt, + }; + }); + + const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( + send: SendCommand, + ) { + yield* send("Page.enable"); + yield* send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { + return yield* new PreviewRecordingAlreadyActiveError({ + requestedTabId: tabId, + activeTabId: recordingTabId.value, + }); + } + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.start", startScreencast); + yield* Ref.set(recordingTabIdRef, Option.some(tabId)); + }); + + const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { + const recordingTabId = yield* Ref.get(recordingTabIdRef); + if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "recording.stop", (send) => + send("Page.stopScreencast").pipe(Effect.asVoid), + ); + yield* Ref.set(recordingTabIdRef, Option.none()); + }); + + const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) { + const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); + 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) => + 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, + path: artifactPath, + mimeType, + sizeBytes: data.byteLength, + createdAt, + }; + }); + + const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) { + const navStatus = tab?.navStatus; + return { + available: false, + visible: true, + tabId, + url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, + title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, + loading: navStatus?.kind === "Loading", + }; + } + const wc = webContents.fromId(tab.webContentsId); + return !wc || wc.isDestroyed() + ? { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + } + : { + available: true, + visible: true, + tabId, + url: wc.getURL() || null, + title: wc.getTitle() || null, + loading: wc.isLoading(), + }; + }); + + const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")( + function* (tabId: string, wc: Electron.WebContents, send: SendCommand) { + yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { + concurrency: 2, + discard: true, + }); + const page = yield* evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + tabId, + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const buildParts = (current, parts = []) => { + if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { + return parts; + } + const parent = current.parentElement; + const siblings = parent + ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) + : []; + const base = current.tagName.toLowerCase(); + const part = siblings.length > 1 + ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" + : base; + return buildParts(parent, [part, ...parts]); + }; + return buildParts(element).join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`, + true, + ); + const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ + send("Accessibility.getFullAXTree"), + attemptPromise( + { + operation: "automationSnapshot.capturePage", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage(), + ), + Ref.get(diagnosticsRef), + Ref.get(actionTimelineRef), + ]); + const sourceSize = sourceImage.getSize(); + const image = + sourceSize.width > MAX_SCREENSHOT_WIDTH + ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) + : sourceImage; + const size = image.getSize(); + const browserDiagnostics = diagnostics.get(wc.id); + return { + ...page, + accessibilityTree: accessibility, + consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], + networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], + actionTimeline: [...(timelines.get(tabId) ?? [])], + screenshot: { + mimeType: "image/png" as const, + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }, + ); + + const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( + tabId: string, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "snapshot", (send) => + captureAutomationSnapshot(tabId, wc, send), + ); + }); + + const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* ( + tabId: string, + send: SendCommand, + input: PreviewAutomationClickInput, + ) { + if (!("selector" in input) && !("locator" in input)) { + return { x: input.x!, y: input.y! }; + } + const locator = automationLocator(input)!; + 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 { + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${locatorJson}); + const element = injected.querySelector(parsed, document, true); + if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in point) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: point.message.length, + cause: point, + }); + } + if ("notFound" in point) { + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "click", + tabId, + ...automationSelectorDiagnostics(input), + }); + } + return point; + }); + + const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* ( + event: DesktopPreviewPointerEvent, + ) { + const listeners = yield* Ref.get(pointerEventListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), + { discard: true }, + ); + }); + + const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + send: SendCommand, + ) { + yield* Effect.all( + [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], + { concurrency: 2, discard: true }, + ); + 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* new PreviewAutomationCoordinatesOutsideViewportError({ + tabId, + x: point.x, + y: point.y, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }); + } + const moveSequence = yield* nextCounter(pointerSequenceRef); + const moveCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "move", + ...point, + sequence: moveSequence, + createdAt: moveCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); + const clickSequence = yield* nextCounter(pointerSequenceRef); + const clickCreatedAt = yield* currentIso; + yield* emitPointerEvent({ + tabId, + phase: "click", + ...point, + sequence: clickSequence, + createdAt: clickCreatedAt, + }); + yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); + yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); + yield* send("Input.dispatchMouseEvent", { + type: "mousePressed", + ...point, + button: "left", + clickCount: 1, + }); + yield* send("Input.dispatchMouseEvent", { + type: "mouseReleased", + ...point, + button: "left", + clickCount: 1, + }); + }); + + const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( + tabId: string, + input: PreviewAutomationClickInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "click", (send) => + performAutomationClick(tabId, input, send), + ); + }); + + const focusAutomationTarget = Effect.fn("PreviewManager.focusAutomationTarget")(function* ( + tabId: string, + send: SendCommand, + input: PreviewAutomationTypeInput, + ) { + const locator = automationLocator(input); + 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 { + const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"}; + if (!element) return { notFound: true }; + element.focus(); + if (${input.clear ?? false}) { + if ("value" in element) element.value = ""; + else if (element.isContentEditable) element.textContent = ""; + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + } + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); + } + if ("notFound" in result) { + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "type", + tabId, + ...automationSelectorDiagnostics(input), + }); + } + }); + + const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + yield* focusAutomationTarget(tabId, send, input); + yield* send("Input.insertText", { text: input.text }); + const textJson = yield* encodeJson( + { operation: "automationType.encodeText", tabId }, + input.text, + ); + yield* evaluateWithDebugger( + tabId, + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, + ); + }); + + const automationType = Effect.fn("PreviewManager.automationType")(function* ( + tabId: string, + input: PreviewAutomationTypeInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "type", (send) => + performAutomationType(tabId, input, send), + ); + }); + + const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + send: SendCommand, + ) { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); + yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }); + + const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( + tabId: string, + input: PreviewAutomationPressInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "press", (send) => + performAutomationPress(tabId, input, send), + ); + }); + + const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + send: SendCommand, + ) { + yield* send("Runtime.enable"); + const locator = automationLocator(input); + if (locator) yield* ensurePlaywrightInjected(tabId, send); + const locatorJson = locator + ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator) + : null; + const result = yield* evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + tabId, + send, + `(() => { + try { + const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + return yield* new PreviewAutomationInvalidSelectorError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + reasonLength: result.message.length, + cause: result, + }); + } + if ("notFound" in result) { + return yield* new PreviewAutomationTargetNotFoundError({ + operation: "scroll", + tabId, + ...automationSelectorDiagnostics(input), + }); + } + }); + + const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( + tabId: string, + input: PreviewAutomationScrollInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "scroll", (send) => + performAutomationScroll(tabId, input, send), + ); + }); + + const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( + 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( + { 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; + }, + ); + + const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) { + const wc = yield* requireWebContents(tabId); + return yield* withControlSession(tabId, wc, "evaluate", (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(tabId, send); + const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ + 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({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes) + : Effect.succeed(null), + ]); + const deadline = (yield* currentMillis) + timeoutMs; + while ((yield* currentMillis) <= deadline) { + const result = yield* evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + tabId, + send, + `(() => { + try { + const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; + const textMatched = ${ + textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true" + }; + const urlMatched = ${ + urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true" + }; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + 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* new PreviewAutomationTimeoutError({ + tabId, + timeoutMs, + }); + }); + + const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) { + const wc = yield* requireWebContents(tabId); + yield* withControlSession(tabId, wc, "waitFor", (send) => + performAutomationWaitFor(tabId, input, send), + ); + }); + + const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* ( + artifactPath: string, + ) { + const resolvedPath = yield* resolveArtifactPath(artifactPath); + 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( + { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, + () => nativeImage.createFromPath(resolvedPath), + ); + if (image.isEmpty()) { + return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); + } + yield* attempt({ operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => + clipboard.writeImage(image), + ); + }); + + const subscribe = ( + ref: Ref.Ref>, + listener: A, + ): Effect.Effect => + Effect.acquireRelease( + Ref.update(ref, (listeners) => new Set([...listeners, listener])), + () => + Ref.update(ref, (listeners) => { + const next = new Set(listeners); + next.delete(listener); + return next; + }), + ).pipe(Effect.asVoid); + + const destroy = Effect.fn("PreviewManager.destroy")(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + yield* Effect.forEach(tabs.keys(), closeTab, { discard: true }); + yield* Effect.all( + [ + Ref.set(listenersRef, new Set()), + Ref.set(expectedAgentInputsRef, new Map()), + Ref.set(pointerEventListenersRef, new Set()), + Ref.set(recordingFrameListenersRef, new Set()), + ], + { discard: true }, + ); + }); + + yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); + + return { + automationClick, + automationEvaluate, + automationPress, + automationScroll, + automationSnapshot, + automationStatus, + automationType, + automationWaitFor, + cancelPickElement, + captureScreenshot, + closeTab, + copyArtifactToClipboard, + createTab, + goBack, + goForward, + hardReload, + navigate, + openDevTools, + pickElement, + refresh, + registerWebview, + resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), + revealArtifact, + saveRecording, + setAnnotationTheme, + setMainWindow, + startRecording, + stopRecording, + subscribePointerEvents: (listener: PointerEventListener) => + subscribe(pointerEventListenersRef, listener), + subscribeRecordingFrames: (listener: RecordingFrameListener) => + subscribe(recordingFrameListenersRef, listener), + subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), + zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), + zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), + }; +}); + +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 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 Schema.TaggedErrorClass()( + "PreviewWebviewNotInitializedError", + { tabId: Schema.String }, +) { + override get message(): string { + return `Preview tab "${this.tabId}" has no webview registered`; + } +} + +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 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 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") {} + +export const make = Effect.gen(function* PreviewManagerMake() { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const browserSession = yield* BrowserSession.BrowserSession; + const operations = yield* makeNativeOperations(environment.browserArtifactsDir); + + return PreviewManager.of({ + setMainWindow: operations.setMainWindow, + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + return yield* browserSession + .getSession(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); + }), + isBrowserPartition: browserSession.isPartition, + createTab: operations.createTab, + closeTab: operations.closeTab, + registerWebview: operations.registerWebview, + navigate: operations.navigate, + goBack: operations.goBack, + goForward: operations.goForward, + refresh: operations.refresh, + zoomIn: operations.zoomIn, + zoomOut: operations.zoomOut, + resetZoom: operations.resetZoom, + hardReload: operations.hardReload, + openDevTools: operations.openDevTools, + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + yield* browserSession + .clearCookies() + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), + ), + ); + }), + clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + yield* browserSession + .clearCache() + .pipe( + Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), + ); + }), + getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { + return yield* browserSession + .getPartition(scope) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); + }), + setAnnotationTheme: operations.setAnnotationTheme, + pickElement: operations.pickElement, + cancelPickElement: operations.cancelPickElement, + captureScreenshot: operations.captureScreenshot, + revealArtifact: operations.revealArtifact, + copyArtifactToClipboard: operations.copyArtifactToClipboard, + startRecording: operations.startRecording, + stopRecording: operations.stopRecording, + saveRecording: operations.saveRecording, + automationStatus: operations.automationStatus, + automationSnapshot: operations.automationSnapshot, + automationClick: operations.automationClick, + automationType: operations.automationType, + automationPress: operations.automationPress, + automationScroll: operations.automationScroll, + automationEvaluate: operations.automationEvaluate, + automationWaitFor: operations.automationWaitFor, + subscribeStateChanges: operations.subscribeStateChanges, + subscribePointerEvents: operations.subscribePointerEvents, + subscribeRecordingFrames: operations.subscribeRecordingFrames, + }); +}).pipe(Effect.withSpan("PreviewManager.make")); + +export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts new file mode 100644 index 000000000000..cf7f3c811f88 --- /dev/null +++ b/apps/desktop/src/preview/PickLabelPosition.ts @@ -0,0 +1,46 @@ +/** + * Pure clamp/flip math for the floating label that follows the cursor while + * the user is picking an element in the in-app browser. Lives in its own + * electron-free module so the geometry can be unit-tested without spinning + * up an Electron preload context (`PickPreload.ts` itself imports + * `electron` and `react-grab/primitives`, which can't load under vitest). + * + * - Horizontally pins the label to `targetLeft`, clamped into + * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. + * - Vertically prefers above the target. If the label would overflow the + * top, flips below; if THAT also overflows the bottom, pins to the + * bottom margin (better to overlap the highlight than disappear). + */ + +/** Distance in CSS pixels between the highlight and the floating label. */ +export const LABEL_GAP = 4; +/** Minimum padding the label keeps from any viewport edge. */ +export const VIEWPORT_MARGIN = 4; + +export function computeLabelPosition(input: { + targetLeft: number; + targetTop: number; + targetBottom: number; + labelWidth: number; + labelHeight: number; + viewportWidth: number; + viewportHeight: number; +}): { x: number; y: number } { + const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; + const { viewportWidth, viewportHeight } = input; + + let x = targetLeft; + const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; + if (x > maxX) x = maxX; + if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; + + let y = targetTop - labelHeight - LABEL_GAP; + if (y < VIEWPORT_MARGIN) { + y = targetBottom + LABEL_GAP; + if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { + y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); + } + } + + return { x, y }; +} diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts new file mode 100644 index 000000000000..5696fe50812e --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { computeLabelPosition } from "./PickLabelPosition.ts"; + +const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; + +describe("computeLabelPosition", () => { + it("anchors to the element's top-left when there's room above and to the right", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(200); + // 200 (top) - 18 (height) - 4 (gap) + expect(y).toBe(200 - 18 - 4); + }); + + it("clamps left edge so the label stays inside the viewport", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -50, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(4); + }); + + it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 1240, + targetTop: 200, + targetBottom: 240, + labelWidth: 200, + labelHeight: 18, + }); + // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 + expect(x).toBe(1076); + }); + + it("flips the label below the element when there's no room above", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 4, + targetBottom: 44, + labelWidth: 120, + labelHeight: 18, + }); + // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 + expect(y).toBe(48); + }); + + it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 0, + targetBottom: 800, + labelWidth: 120, + labelHeight: 18, + }); + // Above overflows top → flip below = 800 + 4 = 804 → also overflows + // bottom → pin to viewportHeight - labelHeight - margin = 778. + expect(y).toBe(800 - 18 - 4); + }); + + it("never returns a negative coordinate", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -1000, + targetTop: -1000, + targetBottom: -900, + labelWidth: 5000, + labelHeight: 5000, + }); + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts new file mode 100644 index 000000000000..2654b8981021 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.ts @@ -0,0 +1,1263 @@ +// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { + DesktopPreviewAnnotationTheme, + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; + +import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const Z_INDEX_OVERLAY = 2147483646; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; + +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; + +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map; +} + +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; +} + +let activeSession: AnnotationSession | null = null; +let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; + +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } +}; + +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; + +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); +}; + +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); + +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; + +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); + +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); + +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; + +function unionRects( + rects: ReadonlyArray, + padding = 20, +): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), + }; +} + +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; +} + +function pickFromPoint(clientX: number, clientY: number): Element | null { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { + if (!(candidate instanceof Element)) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const classes = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((name) => `.${name}`) + .join("") + : ""; + return `${tag}${id}${classes}`; +} + +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; +} + +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} + +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + "white-space:nowrap", + "text-overflow:ellipsis", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; +} + +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; + return; + } + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map(toStackFrame); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function createButton(label: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.className = + "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; + return button; +} + +function styleControl(input: HTMLInputElement | HTMLSelectElement): void { + input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); + input.className = + "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; +} + +function createUnitControl(input: HTMLInputElement): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;min-width:0"; + const unit = document.createElement("span"); + unit.textContent = input.dataset.unit ?? ""; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; + wrapper.append(input, unit); + return wrapper; +} + +function createField( + labelText: string, + input: HTMLInputElement | HTMLSelectElement, +): HTMLLabelElement { + const label = document.createElement("label"); + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const text = document.createElement("span"); + text.textContent = labelText; + styleControl(input); + label.append( + text, + input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, + ); + return label; +} + +function createStyleSection(): HTMLElement { + const section = document.createElement("section"); + section.className = "grid gap-1 border-t border-border py-2"; + return section; +} + +function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.placeholder = placeholder; + input.style.paddingRight = "30px"; + input.dataset.unit = unit; + return input; +} + +function pathFromPoints(points: ReadonlyArray): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); + + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + toolbar.className = + "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + editor.className = + "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(editor); + + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; + + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + ''; + composerRow.appendChild(adjust); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 1; + comment.className = + "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; + composerRow.appendChild(comment); + + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; + composerRow.appendChild(dragHandle); + + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); + + const stylePanel = document.createElement("div"); + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; + editor.appendChild(stylePanel); + + const selected = new Map(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map(); + const toolButtons = new Map(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let editorExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); + + const updateStatus = (): void => { + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); + } + }; + + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); + }; + + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); + } + updateStatus(); + }; + + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(PRIMARY, PRIMARY_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } + }; + + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = PRIMARY; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; + dimensionLabel.innerHTML = "WidthHeight"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '' + : ''; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.className += " h-8 px-2.5 text-sm"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); + } + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; + }; + + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); + }; + + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !editorExpanded) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + queueEditorLayout(); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); + return; + } + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: annotationTheme?.primary ?? "#2563eb", + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; + } + dragStart = null; + updateStatus(); + }; + + const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); + }; + + const onWindowBlur = (): void => { + clearHoverOutline(); + }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + }; + + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + cursorStyle.remove(); + host.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true); + return; + } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); + }; + + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); + + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(host); + refreshToolButtons(); + updateStatus(); + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts new file mode 100644 index 000000000000..d7a967324771 --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; + +function validPayload(overrides?: Record): Record { + return { + pageUrl: "https://example.com/", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +describe("isPickedElementPayload", () => { + it("accepts a complete, well-typed payload", () => { + expect(isPickedElementPayload(validPayload())).toBe(true); + }); + + it("accepts nullable string fields when null", () => { + expect( + isPickedElementPayload( + validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), + ), + ).toBe(true); + }); + + it("accepts an empty stack array", () => { + expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); + }); + + it("accepts stack frames with null fields", () => { + expect( + isPickedElementPayload( + validPayload({ + stack: [ + { + functionName: null, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + ], + }), + ), + ).toBe(true); + }); + + it("rejects null and primitive inputs", () => { + expect(isPickedElementPayload(null)).toBe(false); + expect(isPickedElementPayload(undefined)).toBe(false); + expect(isPickedElementPayload("string")).toBe(false); + expect(isPickedElementPayload(42)).toBe(false); + expect(isPickedElementPayload([])).toBe(false); + }); + + it.each<[string, Record]>([ + ["missing pageUrl", validPayload({ pageUrl: undefined })], + ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], + ["missing tagName", validPayload({ tagName: undefined })], + ["missing htmlPreview", validPayload({ htmlPreview: undefined })], + ["missing styles", validPayload({ styles: undefined })], + ["missing pickedAt", validPayload({ pickedAt: undefined })], + ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], + ["wrong-type selector", validPayload({ selector: 99 })], + ["wrong-type componentName", validPayload({ componentName: 99 })], + ])("rejects payloads with %s", (_label, value) => { + expect(isPickedElementPayload(value)).toBe(false); + }); + + it("rejects malformed source frames", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: 0, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects non-finite numeric line/column numbers", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.POSITIVE_INFINITY, + columnNumber: null, + }, + }), + ), + ).toBe(false); + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.NaN, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects malformed stack arrays", () => { + expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); + expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); + }); +}); + +function validAnnotation(overrides?: Record): Record { + return { + id: "annotation_1", + pageUrl: "https://example.com/", + pageTitle: "Example", + comment: "Make this clearer", + elements: [ + { + id: "element_1", + element: validPayload(), + rect: { x: 10, y: 20, width: 100, height: 40 }, + }, + ], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: "button.submit", + property: "opacity", + previousValue: "1", + value: "0.5", + }, + ], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + ...overrides, + }; +} + +describe("isPreviewAnnotationPayload", () => { + it("accepts a structured annotation draft before screenshot capture", () => { + expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); + }); + + it("rejects screenshots supplied by the guest preload", () => { + expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( + false, + ); + }); + + it("rejects malformed geometry and nested element payloads", () => { + expect( + isPreviewAnnotationPayload( + validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), + ), + ).toBe(false); + expect( + isPreviewAnnotationPayload( + validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts new file mode 100644 index 000000000000..e2d596120dba --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -0,0 +1,146 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) + * via `wc.ipc`. Lives in its own electron-free module so the validator is + * trivially unit-testable. + * + * Validation must be tight: downstream `normalizeElementContextSelection` + * calls `.trim()` on incoming strings, so a malformed payload (preload bug, + * future schema mismatch, malicious page that intercepts the preload's IPC + * channel via prototype pollution) would otherwise throw deep in the + * renderer and the chip silently never appears. + */ +import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isFiniteNumberOrNull(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isPickedStackFrame(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const frame = value as Record; + return ( + isStringOrNull(frame["functionName"]) && + isStringOrNull(frame["fileName"]) && + isFiniteNumberOrNull(frame["lineNumber"]) && + isFiniteNumberOrNull(frame["columnNumber"]) + ); +} + +export function isPickedElementPayload(value: unknown): value is PickedElementPayload { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + if (typeof c["pageUrl"] !== "string") return false; + if (typeof c["tagName"] !== "string") return false; + if (typeof c["htmlPreview"] !== "string") return false; + if (typeof c["styles"] !== "string") return false; + if (typeof c["pickedAt"] !== "string") return false; + if (!isStringOrNull(c["pageTitle"])) return false; + if (!isStringOrNull(c["selector"])) return false; + if (!isStringOrNull(c["componentName"])) return false; + if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; + if (!Array.isArray(c["stack"])) return false; + if (!c["stack"].every(isPickedStackFrame)) return false; + return true; +} + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record; + return ["x", "y", "width", "height"].every( + (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), + ); +} + +function isPoint(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const point = value as Record; + return ( + typeof point["x"] === "number" && + Number.isFinite(point["x"]) && + typeof point["y"] === "number" && + Number.isFinite(point["y"]) + ); +} + +export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { + if (typeof value !== "object" || value === null) return false; + const annotation = value as Record; + if (typeof annotation["id"] !== "string") return false; + if (typeof annotation["pageUrl"] !== "string") return false; + if (!isStringOrNull(annotation["pageTitle"])) return false; + if (typeof annotation["comment"] !== "string") return false; + if (typeof annotation["createdAt"] !== "string") return false; + if (annotation["screenshot"] !== null) return false; + + const elements = annotation["elements"]; + if (!Array.isArray(elements)) return false; + if ( + !elements.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + isPickedElementPayload(target["element"]) && + isRect(target["rect"]) + ); + }) + ) { + return false; + } + + const regions = annotation["regions"]; + if (!Array.isArray(regions)) return false; + if ( + !regions.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return typeof target["id"] === "string" && isRect(target["rect"]); + }) + ) { + return false; + } + + const strokes = annotation["strokes"]; + if (!Array.isArray(strokes)) return false; + if ( + !strokes.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + typeof target["color"] === "string" && + typeof target["width"] === "number" && + Number.isFinite(target["width"]) && + Array.isArray(target["points"]) && + target["points"].every(isPoint) && + isRect(target["bounds"]) + ); + }) + ) { + return false; + } + + const styleChanges = annotation["styleChanges"]; + if (!Array.isArray(styleChanges)) return false; + if ( + !styleChanges.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const change = entry as Record; + return ( + typeof change["targetId"] === "string" && + isStringOrNull(change["selector"]) && + typeof change["property"] === "string" && + typeof change["previousValue"] === "string" && + typeof change["value"] === "string" + ); + }) + ) { + return false; + } + return true; +} diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts new file mode 100644 index 000000000000..cd7fee1e3c78 --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -0,0 +1,80 @@ +import { it as effectIt } from "@effect/vitest"; +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* () { + const source = yield* playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }), + ); + + effectIt.effect("builds an idempotent install expression", () => + Effect.gen(function* () { + const expression = yield* playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + 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 new file mode 100644 index 000000000000..ff1531f08f3c --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection. +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 Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +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 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`; + } +} + +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_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: () => NodeFSP.readFile(bundlePath, "utf8"), + catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }), + }); + return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath); + }, +); + +export const playwrightInjectedRuntimeInstallExpression = Effect.fn( + "PlaywrightInjectedRuntime.installExpression", +)(function* () { + const source = yield* playwrightInjectedRuntimeSource(); + const options = yield* encodeUnknownJson({ + isUnderTest: false, + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: PLAYWRIGHT_BROWSER_NAME, + shouldPrependErrorPrefix: false, + isUtilityWorld: false, + customEngines: [], + }).pipe( + Effect.mapError( + (cause) => + new PlaywrightOptionsEncodeError({ + sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE, + browserName: PLAYWRIGHT_BROWSER_NAME, + cause, + }), + ), + ); + return `(() => { + if (globalThis.__t3PlaywrightInjected) return true; + const module = { exports: {} }; + ${source} + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options}); + return true; + })()`; +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts new file mode 100644 index 000000000000..498c1df4665b --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; + +/** + * Mirrors Electron's webview attribute parser closely enough to catch the + * regressions we've already hit: + * + * - whitespace inside the comma-separated list silently drops keys (so + * `" sandbox=true"` becomes an unknown key and Electron falls back to + * defaults — re-opening the Node-leak window we closed), + * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy + * strings and assigned to a boolean preference, which silently flips + * `contextIsolation=no` to ENABLED (then react-grab can't see the React + * DevTools hook and componentName resolution always returns null). + * + * The actual Electron parser does roughly: + * + * for (const pair of webpreferences.split(',')) { + * const [key, value] = pair.split('='); + * prefs[key] = value; // value left as a string + * } + * + * then later coerces booleans via `Boolean(value)`. Replicating that here + * keeps the test independent of Electron internals while still failing if + * we accidentally ship `"contextIsolation=no"` again. + */ +function parseWebPreferences(input: string): Record { + const out: Record = {}; + for (const pair of input.split(",")) { + if (pair !== pair.trim()) { + // Electron's parser doesn't trim; surface the bug as undefined-key. + out[pair] = pair.split("=")[1]; + continue; + } + const [key, value] = pair.split("="); + if (!key) continue; + out[key] = value; + } + return out; +} + +describe("PREVIEW_WEBVIEW_PREFERENCES", () => { + const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); + + it("contains exactly the three security-critical keys", () => { + expect(Object.keys(parsed).toSorted()).toEqual( + ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), + ); + }); + + it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { + // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so + // `contextIsolation="no"` would silently leave isolation ENABLED. Lock + // the values to `"true"` / `"false"` so the parser does the right thing. + for (const value of Object.values(parsed)) { + expect(value).toMatch(/^(true|false)$/); + } + }); + + it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { + expect(parsed["contextIsolation"]).toBe("false"); + }); + + it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { + expect(parsed["sandbox"]).toBe("true"); + }); + + it("disables nodeIntegration (defense in depth — page never gets Node)", () => { + expect(parsed["nodeIntegration"]).toBe("false"); + }); + + it("contains no whitespace (Electron's parser does not trim)", () => { + // Electron splits on `,` without trimming, so any whitespace would turn + // a key into an unknown one and silently drop the security flag. + expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); + }); +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts new file mode 100644 index 000000000000..085c75232b38 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -0,0 +1,42 @@ +/** + * webPreferences override applied to every preview `` element via + * its `webpreferences="..."` attribute. Single source of truth so all guest + * surfaces inherit the same security posture. + * + * Lives in its own electron-free module so the value is unit-testable + * without importing `Manager.ts` (which transitively imports + * `electron` and blows up under vitest). + * + * - `contextIsolation=false`: the picker preload needs to share `globalThis` + * with the page so react-grab/bippy can read the React DevTools hook + * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without + * this every pick comes back with `componentName: null` even on dev React + * apps. + * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical + * when paired with `contextIsolation=false` — without sandbox, the preload + * has full Node access (`require`, `fs`, `child_process`, ...) and that + * `require` would land on the page's shared `globalThis`, giving any + * third-party page in the preview full Node + IPC access to the host. + * In sandboxed mode Electron still synthesizes the `electron` module for + * the preload's `import { ipcRenderer }` line, but no Node globals leak. + * - `nodeIntegration=false`: pinned for clarity (the page itself never gets + * Node access). + * + * Format notes (locked down by `WebviewPreferences.test.ts`): + * - Whitespace-free. Electron's webpreferences parser splits on `,` and + * does not trim, so a leading space would turn a key into an unknown one + * and silently drop it. + * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not + * special-cased by the parser; `value="no"` becomes the truthy STRING + * `"no"` when assigned to a boolean webPreferences key. Most critically, + * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → + * react-grab can't see the React DevTools hook. + * + * Defense in depth: `apps/desktop/src/main.ts` also runs a + * `will-attach-webview` handler that force-sets `sandbox: true` and + * `nodeIntegration*: false` on the actual webPreferences object, gated on + * the preview partition, so even if this string is ever wrong, the + * security-critical flags can't regress on preview tabs. + */ +export const PREVIEW_WEBVIEW_PREFERENCES = + "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f70..c76ffa8bbdae 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 a54f22fec5b1..81aae92f0a3f 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 000000000000..5034df44cf70 --- /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 f666e692860c..ea7ec6e1512c 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"; @@ -17,7 +18,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, - diffWordWrap: true, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -28,13 +28,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", @@ -106,6 +106,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 68d3fdc904ac..4ff091e27a27 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 d1d37b96e118..ec70308b3d34 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 531b50ba73b7..bdda7f9c7386 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 897e7336a248..7ec0ab80ae74 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 13ac35b6297a..8219f18b7a53 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 77c86be39d2e..1fe2b86aae7b 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 595d3bea304b..31e84ae995ed 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 080a2fe465d0..5ec7dd65d1e2 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 1d50f9ca3255..aa25d8135c7a 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 34d18f11a77d..4c90afb2a126 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 e6c81d8d25be..aecbdcfc3e8a 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 62d619fe18b4..04a1971ce461 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 2d41fa9db86d..a52707627b0a 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 5e977de2dea9..76413dd0b550 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -8,6 +8,17 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import { vi } from "vite-plus/test"; +vi.mock("electron", async (importOriginal) => ({ + ...(await importOriginal()), + session: { + fromPartition: vi.fn(() => ({ + getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), + setPermissionRequestHandler: vi.fn(), + setUserAgent: vi.fn(), + })), + }, +})); + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -18,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -56,6 +68,7 @@ function makeFakeBrowserWindow() { once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), + setAutoHideCursor: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), show: vi.fn(), @@ -66,6 +79,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; } @@ -77,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"), @@ -92,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( @@ -122,10 +136,17 @@ function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + create: (options) => + Effect.sync(() => { + input.createdWindowOptions?.push(options); + }).pipe( + Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.as(input.window), + ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), focusedMainOrFirst: Ref.get(input.mainWindow), @@ -135,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( @@ -152,9 +173,15 @@ function makeTestLayer(input: { return true; }), copyText: () => Effect.void, - } satisfies ElectronShell.ElectronShellShape), + } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + }), ), ), ); @@ -164,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", }), ); @@ -187,10 +214,12 @@ describe("DesktopWindow", () => { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + createdWindowOptions, }); yield* Effect.gen(function* () { @@ -200,7 +229,9 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady; assert.equal(yield* Ref.get(createCount), 1); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); + 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 35145cc1d536..e6cfce3c54fe 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,14 +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 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 @@ -31,57 +31,40 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets - | DesktopServerExposure.DesktopServerExposure | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; - -export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( - "DesktopWindowDevServerUrlMissingError", -)<{}> { - override get message() { - return "VITE_DEV_SERVER_URL is required in desktop development."; - } -} + | ElectronWindow.ElectronWindow + | PreviewManager.PreviewManager; export type DesktopWindowError = - | DesktopWindowDevServerUrlMissingError - | ElectronWindow.ElectronWindowCreateError; - -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", -) {} + | ElectronWindow.ElectronWindowCreateError + | PreviewManager.PreviewManagerError; + +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, + platform: NodeJS.Platform, ): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), onSome: (icon) => ({ icon }), @@ -103,8 +86,11 @@ export function isSameOriginRendererNavigation(input: { } } -function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { - if (process.platform === "darwin") { +function getWindowTitleBarOptions( + shouldUseDarkColors: boolean, + platform: NodeJS.Platform, +): WindowTitleBarOptions { + if (platform === "darwin") { return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, @@ -124,6 +110,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -131,7 +118,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -155,26 +142,26 @@ 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; const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const previewManager = yield* PreviewManager.PreviewManager; 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 applicationUrl = environment.isDevelopment - ? yield* resolveDesktopDevServerUrl(environment) - : backendHttpUrl.href; + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< + Electron.BrowserWindow, + DesktopWindowError + > { + yield* previewManager.getBrowserSession(); + const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; - const iconOption = getIconOption(iconPaths); + const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const window = yield* electronWindow.create({ width: 1100, @@ -183,18 +170,39 @@ const make = Effect.gen(function* () { minHeight: 620, show: false, autoHideMenuBar: true, + ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), + ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, + webviewTag: true, }, }); + if (environment.platform === "darwin") { + window.setAutoHideCursor(false); + } + + yield* previewManager.setMainWindow(window); + window.webContents.on("will-attach-webview", (event, webPreferences, params) => { + if ( + typeof params.partition !== "string" || + !previewManager.isBrowserPartition(params.partition) + ) { + event.preventDefault(); + return; + } + webPreferences.sandbox = true; + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = false; + }); + window.webContents.on("context-menu", (event, params) => { event.preventDefault(); @@ -297,7 +305,7 @@ const make = Effect.gen(function* () { }); const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { + if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { @@ -319,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; @@ -373,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)); }; @@ -387,7 +394,7 @@ const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors), + syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d42d22309465..96e089b91833 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,17 +14,18 @@ export default defineConfig({ run: { tasks: { build: { - command: "vp pack", + command: "node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { - command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + command: + "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "vp pack --watch", + command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { @@ -55,6 +56,22 @@ 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", + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/preview-pick-preload.ts"], + deps: { + alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + }, }, ], }); diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index e60637cbfd1c..5d9fc4e8f3bc 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 000000000000..5ff5958c588f --- /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 0b76f3508968..69de2088d43f 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 83fc429b7312..0714ce90e635 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 7cbb8335deb6..8cdf6f2e25ce 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/clerk-theme.json b/apps/mobile/clerk-theme.json index 52941785f3e7..119927a04d60 100644 --- a/apps/mobile/clerk-theme.json +++ b/apps/mobile/clerk-theme.json @@ -13,7 +13,7 @@ "neutral": "#F5F5F5", "border": "#E5E5EA", "ring": "#A3A3A3", - "muted": "#F5F5F5", + "muted": "#F2F2F7", "shadow": "#000000" }, "darkColors": { @@ -30,7 +30,7 @@ "neutral": "#1C1C1C", "border": "#2A2A2A", "ring": "#525252", - "muted": "#1C1C1C", + "muted": "#0E0E0E", "shadow": "#000000" }, "design": { diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 4e6b55a4223a..14c5ea58669b 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 4642879451a0..b2014bf9353e 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -18,7 +18,7 @@ --color-foreground: #262626; --color-foreground-secondary: #525252; --color-foreground-muted: #737373; - --color-foreground-tertiary: #a3a3a3; + --color-foreground-tertiary: #8e8e93; /* Borders & separators */ --color-border: rgba(0, 0, 0, 0.08); @@ -28,6 +28,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(0, 0, 0, 0.04); --color-subtle-strong: rgba(0, 0, 0, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #a21caf; /* Primary action */ --color-primary: #262626; @@ -58,6 +61,8 @@ /* Header / glass chrome */ --color-header: rgba(255, 255, 255, 0.97); --color-header-border: rgba(0, 0, 0, 0.06); + --color-glass-surface: rgba(255, 255, 255, 0.72); + --color-glass-tint: rgba(255, 255, 255, 0.18); /* StatusBar */ --color-status-bar: #f2f2f7; @@ -105,8 +110,8 @@ /* Text */ --color-foreground: #f5f5f5; --color-foreground-secondary: #a3a3a3; - --color-foreground-muted: #737373; - --color-foreground-tertiary: #525252; + --color-foreground-muted: #8e8e93; + --color-foreground-tertiary: #636366; /* Borders & separators */ --color-border: rgba(255, 255, 255, 0.06); @@ -116,6 +121,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(255, 255, 255, 0.04); --color-subtle-strong: rgba(255, 255, 255, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #f0abfc; /* Primary action */ --color-primary: #f5f5f5; @@ -136,16 +144,18 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); - --color-placeholder: #737373; + --color-placeholder: #8e8e93; /* Icons */ --color-icon: #f5f5f5; --color-icon-muted: #a3a3a3; - --color-icon-subtle: #737373; + --color-icon-subtle: #8e8e93; /* Header / glass chrome */ --color-header: rgba(10, 10, 10, 0.97); --color-header-border: rgba(255, 255, 255, 0.06); + --color-glass-surface: rgba(23, 23, 23, 0.78); + --color-glass-tint: rgba(23, 23, 23, 0.24); /* StatusBar */ --color-status-bar: #0a0a0a; @@ -182,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/LICENSE b/apps/mobile/modules/t3-composer-editor/LICENSE new file mode 100644 index 000000000000..30b20e3b5f0f --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json new file mode 100644 index 000000000000..0d6384cd91ac --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3ComposerEditorModule"] + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec new file mode 100644 index 000000000000..57c09fa95357 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'T3ComposerEditor' + s.version = '1.0.0' + s.summary = 'Native attributed composer editor for T3 Code mobile.' + s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '16.4', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift new file mode 100644 index 000000000000..5d3b33094cb3 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -0,0 +1,71 @@ +import ExpoModulesCore + +public class T3ComposerEditorModule: Module { + public func definition() -> ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView.self) { + Prop("value") { (view: T3ComposerEditorView, value: String) in + view.setValue(value) + } + Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in + view.setSelectionJson(selectionJson) + } + Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in + view.setThemeJson(themeJson) + } + Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in + view.setFontFamily(fontFamily) + } + Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in + view.setFontSize(CGFloat(fontSize)) + } + Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in + view.setLineHeight(CGFloat(lineHeight)) + } + Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in + view.setContentInsetVertical(CGFloat(contentInsetVertical)) + } + Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in + view.setEditable(editable) + } + Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange" + ) + + AsyncFunction("focus") { (view: T3ComposerEditorView) in + view.focusEditor() + } + AsyncFunction("blur") { (view: T3ComposerEditorView) in + view.blurEditor() + } + AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in + view.setSelection(start: start, end: end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift new file mode 100644 index 000000000000..6f4dc575b123 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -0,0 +1,842 @@ +import ExpoModulesCore +import UIKit + +private struct ComposerTokenPayload: Decodable { + let type: String + let source: String + let label: String + let iconUri: String? + let start: Int + let end: Int +} + +private struct ComposerSelectionPayload: Decodable { + let start: Int + let end: Int +} + +private struct ComposerThemePayload: Decodable { + let text: String + let placeholder: String + let chipBackground: String + let chipBorder: String + let chipText: String + let skillBackground: String + let skillBorder: String + let skillText: String + let fileTint: String +} + +private struct ComposerChipStyle { + let tint: UIColor + let backgroundColor: UIColor + let borderColor: UIColor + let textColor: UIColor +} + +private final class ComposerTextAttachment: NSTextAttachment { + let source: String + + init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { + self.source = source + super.init(data: nil, ofType: nil) + self.image = image + bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) + } + + required init?(coder: NSCoder) { + nil + } +} + +private final class ComposerTextView: UITextView { + private static let pastedImageDirectoryName = "t3-composer-paste" + private static let stalePastedImageAge: TimeInterval = 60 * 60 + + var onPasteImages: (([String]) -> Void)? + var onAttributedMutation: (() -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + let pasteboard = UIPasteboard.general + if pasteboard.hasImages || + pasteboard.itemProviders.contains(where: { + $0.canLoadObject(ofClass: UIImage.self) + }) { + return true + } + } + return super.canPerformAction(action, withSender: sender) + } + + override func paste(_ sender: Any?) { + let pasteboard = UIPasteboard.general + let imageProviders = pasteboard.itemProviders.filter { + $0.canLoadObject(ofClass: UIImage.self) + } + if !imageProviders.isEmpty { + loadImages(from: imageProviders) + return + } + + let images = pasteboard.images ?? [] + if !images.isEmpty { + let urls = images.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + onPasteImages?(urls) + return + } + } + super.paste(sender) + } + + override func deleteBackward() { + guard selectedRange.length == 0, selectedRange.location > 0 else { + super.deleteBackward() + return + } + + let previousOffset = selectedRange.location - 1 + if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) + is ComposerTextAttachment { + replaceDisplayRange(NSRange(location: previousOffset, length: 1)) + return + } + + super.deleteBackward() + } + + private func replaceDisplayRange(_ range: NSRange) { + guard let start = position(from: beginningOfDocument, offset: range.location), + let end = position(from: start, offset: range.length), + let textRange = textRange(from: start, to: end) else { + return + } + replace(textRange, withText: "") + } + + private func loadImages(from providers: [NSItemProvider]) { + let group = DispatchGroup() + let lock = NSLock() + var images = [UIImage?](repeating: nil, count: providers.count) + + for (index, provider) in providers.enumerated() { + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + defer { group.leave() } + guard let image = object as? UIImage else { + return + } + lock.lock() + images[index] = image + lock.unlock() + } + } + + group.notify(queue: .main) { [weak self] in + let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + self?.onPasteImages?(urls) + } + } + } + + override func copy(_ sender: Any?) { + guard selectedRange.length > 0 else { + return super.copy(sender) + } + UIPasteboard.general.string = serializedText(in: selectedRange) + } + + override func cut(_ sender: Any?) { + guard isEditable, selectedRange.length > 0 else { + return super.cut(sender) + } + copy(sender) + textStorage.replaceCharacters(in: selectedRange, with: "") + selectedRange = NSRange(location: selectedRange.location, length: 0) + onAttributedMutation?() + } + + func serializedText() -> String { + serializedText(in: NSRange(location: 0, length: attributedText.length)) + } + + func serializedText(in range: NSRange) -> String { + guard range.length > 0 else { + return "" + } + + let source = NSMutableString() + let nsString = attributedText.string as NSString + var cursor = range.location + let end = NSMaxRange(range) + attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in + if attachmentRange.location > cursor { + source.append( + nsString.substring( + with: NSRange(location: cursor, length: attachmentRange.location - cursor) + ) + ) + } + if let attachment = value as? ComposerTextAttachment { + source.append(attachment.source) + } else { + source.append(nsString.substring(with: attachmentRange)) + } + cursor = NSMaxRange(attachmentRange) + } + if cursor < end { + source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) + } + return source as String + } + + func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { + let boundedOffset = max(0, min(attributedText.length, displayOffset)) + if boundedOffset == 0 { + return 0 + } + + var sourceOffset = 0 + let range = NSRange(location: 0, length: boundedOffset) + attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in + if let attachment = value as? ComposerTextAttachment { + sourceOffset += (attachment.source as NSString).length + } else { + sourceOffset += attributeRange.length + } + } + return sourceOffset + } + + private static func writeTemporaryImage(_ image: UIImage) -> String? { + guard let data = image.pngData() else { + return nil + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(pastedImageDirectoryName, isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + removeStaleTemporaryImages(in: directory) + let url = directory.appendingPathComponent("\(UUID().uuidString).png") + try data.write(to: url, options: .atomic) + return url.absoluteString + } catch { + return nil + } + } + + private static func removeStaleTemporaryImages(in directory: URL) { + let cutoff = Date().addingTimeInterval(-stalePastedImageAge) + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + for url in urls { + guard + let values = try? url.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey] + ), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + modifiedAt < cutoff + else { + continue + } + try? FileManager.default.removeItem(at: url) + } + } +} + +public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { + private let textView = ComposerTextView() + private let placeholderLabel = UILabel() + private var value = "" + private var tokensJson = "[]" + private var tokens: [ComposerTokenPayload] = [] + private var requestedSelection: ComposerSelectionPayload? + private var theme = ComposerThemePayload( + text: "#262626", + placeholder: "#8e8e93", + chipBackground: "#f2f2f7", + chipBorder: "#dedee3", + chipText: "#262626", + skillBackground: "#f9e8fb", + skillBorder: "#e5a6eb", + skillText: "#a21caf", + fileTint: "#737373" + ) + private var fontFamily = "DMSans_400Regular" + private var fontSize: CGFloat = 14 + private var lineHeight: CGFloat = 20 + private var contentInsetVertical: CGFloat = 0 + private var shouldAutoFocus = false + private var didAutoFocus = false + private var isApplyingControlledValue = false + private var lastContentSize = CGSize.zero + private var iconImages: [String: UIImage] = [:] + private var pendingIconUris = Set() + private var tokensNeedRebuild = false + + let onComposerChange = EventDispatcher() + let onComposerSelectionChange = EventDispatcher() + let onComposerFocus = EventDispatcher() + let onComposerBlur = EventDispatcher() + let onComposerPasteImages = EventDispatcher() + let onComposerContentSizeChange = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + clipsToBounds = false + textView.delegate = self + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.keyboardDismissMode = .interactive + textView.alwaysBounceVertical = false + textView.showsVerticalScrollIndicator = true + textView.adjustsFontForContentSizeCategory = true + textView.onPasteImages = { [weak self] urls in + self?.onComposerPasteImages(["uris": urls]) + } + textView.onAttributedMutation = { [weak self] in + self?.emitTextChange() + } + addSubview(textView) + + placeholderLabel.numberOfLines = 0 + placeholderLabel.adjustsFontForContentSizeCategory = true + addSubview(placeholderLabel) + applyTypography() + applyTheme() + } + + public override func layoutSubviews() { + super.layoutSubviews() + textView.frame = bounds + let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding + let placeholderY = textView.textContainerInset.top + let placeholderWidth = max( + 0, + bounds.width - placeholderX - textView.textContainerInset.right - + textView.textContainer.lineFragmentPadding + ) + placeholderLabel.frame = CGRect( + x: placeholderX, + y: placeholderY, + width: placeholderWidth, + height: max(lineHeight, placeholderLabel.font.lineHeight) + ) + emitContentSizeIfNeeded() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, shouldAutoFocus, !didAutoFocus else { + return + } + didAutoFocus = true + DispatchQueue.main.async { [weak self] in + self?.textView.becomeFirstResponder() + } + } + + func setValue(_ value: String) { + self.value = value + applyControlledDocument(force: tokensNeedRebuild) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setTokensJson(_ tokensJson: String) { + guard self.tokensJson != tokensJson else { + return + } + self.tokensJson = tokensJson + tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] + tokensNeedRebuild = true + applyControlledDocument(force: true) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setSelectionJson(_ selectionJson: String) { + requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) + applyRequestedSelection() + } + + func setThemeJson(_ themeJson: String) { + guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { + return + } + theme = nextTheme + applyTheme() + applyControlledDocument(force: true) + } + + func setPlaceholder(_ placeholder: String) { + placeholderLabel.text = placeholder + setNeedsLayout() + } + + func setFontFamily(_ fontFamily: String) { + self.fontFamily = fontFamily + applyTypography() + applyControlledDocument(force: true) + } + + func setFontSize(_ fontSize: CGFloat) { + self.fontSize = fontSize + applyTypography() + applyControlledDocument(force: true) + } + + func setLineHeight(_ lineHeight: CGFloat) { + self.lineHeight = lineHeight + applyTypography() + applyControlledDocument(force: true) + } + + func setContentInsetVertical(_ contentInsetVertical: CGFloat) { + self.contentInsetVertical = contentInsetVertical + textView.textContainerInset = UIEdgeInsets( + top: contentInsetVertical, + left: 0, + bottom: contentInsetVertical, + right: 0 + ) + setNeedsLayout() + } + + func setEditable(_ editable: Bool) { + textView.isEditable = editable + } + + func setScrollEnabled(_ scrollEnabled: Bool) { + textView.isScrollEnabled = scrollEnabled + } + + func setAutoFocus(_ autoFocus: Bool) { + shouldAutoFocus = autoFocus + } + + func setAutoCorrect(_ autoCorrect: Bool) { + textView.autocorrectionType = autoCorrect ? .yes : .no + } + + func setSpellCheck(_ spellCheck: Bool) { + textView.spellCheckingType = spellCheck ? .yes : .no + } + + func focusEditor() { + textView.becomeFirstResponder() + } + + func blurEditor() { + textView.resignFirstResponder() + } + + func setSelection(start: Int, end: Int) { + requestedSelection = ComposerSelectionPayload(start: start, end: end) + applyRequestedSelection() + } + + public func textViewDidChange(_ textView: UITextView) { + emitTextChange() + } + + public func textViewDidChangeSelection(_ textView: UITextView) { + 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() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + onComposerBlur() + } + + private func applyControlledDocument(force: Bool = false) { + let currentSource = textView.serializedText() + guard force || currentSource != value || !documentMatchesExpectedTokens() else { + updatePlaceholderVisibility() + return + } + + let previousSelection = sourceSelection() + isApplyingControlledValue = true + textView.attributedText = makeAttributedDocument() + let targetSelection = requestedSelection ?? previousSelection + requestedSelection = nil + textView.selectedRange = displayRange(for: targetSelection) + restoreBaseTypingAttributes() + isApplyingControlledValue = false + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func makeAttributedDocument() -> NSAttributedString { + let result = NSMutableAttributedString() + let source = value as NSString + var cursor = 0 + let validTokens = tokens.filter { + $0.start >= cursor && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + + for token in validTokens { + if token.start < cursor { + continue + } + if token.start > cursor { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: token.start - cursor)), + to: result + ) + } + result.append(makeAttachmentString(token)) + cursor = token.end + } + if cursor < source.length { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: source.length - cursor)), + to: result + ) + } + return result + } + + private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { + result.append(NSAttributedString(string: text, attributes: baseAttributes())) + } + + private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { + let isSkill = token.type == "skill" + let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel + let iconName = isSkill ? "cube" : "doc" + let iconImage = token.iconUri.flatMap(iconImage(for:)) + let style = ComposerChipStyle( + tint: tint, + backgroundColor: UIColor( + composerHex: isSkill ? theme.skillBackground : theme.chipBackground + ) ?? .secondarySystemFill, + borderColor: UIColor( + composerHex: isSkill ? theme.skillBorder : theme.chipBorder + ) ?? .separator, + textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label + ) + let image = renderChip( + label: token.label, + iconName: iconName, + iconImage: iconImage, + style: style + ) + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let baselineOffset = floor((font.capHeight - image.size.height) / 2) + let attachment = ComposerTextAttachment( + source: token.source, + image: image, + size: image.size, + baselineOffset: baselineOffset + ) + let attributedAttachment = NSMutableAttributedString(attachment: attachment) + attributedAttachment.addAttributes( + baseAttributes(), + range: NSRange(location: 0, length: attributedAttachment.length) + ) + return attributedAttachment + } + + private func renderChip( + label: String, + iconName: String, + iconImage: UIImage?, + style: ComposerChipStyle + ) -> UIImage { + let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) + let fallbackIcon = UIImage( + systemName: iconName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + ) + let icon = iconImage ?? fallbackIcon + let textSize = (label as NSString).size(withAttributes: [.font: font]) + let iconWidth = icon == nil ? 0 : 14 + let iconGap = icon == nil ? 0 : 5 + let height: CGFloat = 24 + let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9) + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + return renderer.image { context in + let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) + let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7) + style.backgroundColor.setFill() + path.fill() + style.borderColor.setStroke() + path.lineWidth = 1 + path.stroke() + + var x: CGFloat = 9 + if let icon { + let renderedIcon = iconImage == nil + ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) + : icon + renderedIcon.draw( + in: CGRect(x: x, y: 5, width: 14, height: 14) + ) + x += 19 + } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + (label as NSString).draw( + in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18), + withAttributes: [ + .font: font, + .foregroundColor: style.textColor, + .paragraphStyle: paragraph, + ] + ) + context.cgContext.setAllowsAntialiasing(true) + } + } + + private func iconImage(for uri: String) -> UIImage? { + if let image = iconImages[uri] { + return image + } + guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { + return nil + } + + if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { + iconImages[uri] = image + return image + } + + pendingIconUris.insert(uri) + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, let data, let image = UIImage(data: data) else { + DispatchQueue.main.async { + self?.pendingIconUris.remove(uri) + } + return + } + DispatchQueue.main.async { + self.pendingIconUris.remove(uri) + self.iconImages[uri] = image + self.applyControlledDocument(force: true) + } + }.resume() + return nil + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + return [ + .font: font, + .foregroundColor: UIColor(composerHex: theme.text) ?? .label, + .paragraphStyle: paragraph, + ] + } + + private func applyTypography() { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + textView.font = font + 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 + tintColor = UIColor.systemBlue + } + + private func emitTextChange() { + guard !isApplyingControlledValue else { + return + } + value = textView.serializedText() + let selection = sourceSelection() + onComposerChange([ + "value": value, + "selection": ["start": selection.start, "end": selection.end], + ]) + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func emitSelection() { + let selection = sourceSelection() + onComposerSelectionChange([ + "selection": ["start": selection.start, "end": selection.end], + ]) + } + + private func sourceSelection() -> ComposerSelectionPayload { + ComposerSelectionPayload( + start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), + end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) + ) + } + + private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { + let start = displayOffset(forSourceOffset: selection.start) + let end = displayOffset(forSourceOffset: selection.end) + return NSRange(location: start, length: max(0, end - start)) + } + + private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { + let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) + var collapsedLength = 0 + for token in tokens where token.end <= boundedOffset { + collapsedLength += max(0, token.end - token.start - 1) + } + if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { + return token.start - collapsedLength + 1 + } + return boundedOffset - collapsedLength + } + + private func applyRequestedSelection() { + guard let requestedSelection else { + return + } + let nextRange = displayRange(for: requestedSelection) + guard nextRange.location <= textView.attributedText.length, + NSMaxRange(nextRange) <= textView.attributedText.length else { + return + } + isApplyingControlledValue = true + textView.selectedRange = nextRange + isApplyingControlledValue = false + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !value.isEmpty + } + + private func emitContentSizeIfNeeded() { + let nextSize = textView.contentSize + guard abs(nextSize.width - lastContentSize.width) > 0.5 || + abs(nextSize.height - lastContentSize.height) > 0.5 else { + return + } + lastContentSize = nextSize + onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) + } + + private func decode(_ type: T.Type, from json: String) -> T? { + guard let data = json.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(type, from: data) + } + + private func tokensMatchCurrentValue() -> Bool { + let source = value as NSString + return tokens.allSatisfy { + $0.start >= 0 && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + } + + private func documentMatchesExpectedTokens() -> Bool { + let source = value as NSString + let expectedSources = tokens.compactMap { token -> String? in + guard token.start >= 0, + token.end > token.start, + token.end <= source.length, + source.substring( + with: NSRange(location: token.start, length: token.end - token.start) + ) == token.source else { + return nil + } + return token.source + } + var renderedSources: [String] = [] + textView.attributedText.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + if let attachment = value as? ComposerTextAttachment { + renderedSources.append(attachment.source) + } + } + return renderedSources == expectedSources + } +} + +private extension UIColor { + convenience init?(composerHex hex: String?) { + guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6 || value.count == 8, + let raw = UInt64(value, radix: 16) else { + return nil + } + if value.count == 8 { + self.init( + red: CGFloat((raw >> 24) & 0xff) / 255, + green: CGFloat((raw >> 16) & 0xff) / 255, + blue: CGFloat((raw >> 8) & 0xff) / 255, + alpha: CGFloat(raw & 0xff) / 255 + ) + } else { + self.init( + red: CGFloat((raw >> 16) & 0xff) / 255, + green: CGFloat((raw >> 8) & 0xff) / 255, + blue: CGFloat(raw & 0xff) / 255, + alpha: 1 + ) + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-markdown-text/LICENSE new file mode 100644 index 000000000000..9aa27cb649d9 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024-25 Bluesky PBC +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec new file mode 100644 index 000000000000..0ac471faf24d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec @@ -0,0 +1,25 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" + +Pod::Spec.new do |s| + s.name = "T3MarkdownText" + s.version = package["version"] + s.summary = "Native selectable markdown renderer for T3 Code mobile." + s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." + s.homepage = "https://t3tools.com" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "T3 Tools" => "hello@t3tools.com" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :path => "." } + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + install_modules_dependencies(s) + + if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled + add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ + "react/renderer/textlayoutmanager/platform/ios", + ]) + end +end diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md new file mode 100644 index 000000000000..0ddc7775a9ec --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md @@ -0,0 +1,12 @@ +# Upstream Attribution + +The Fabric attributed-text component in this module originated from +[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), +version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. + +The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under +the MIT License included in this directory. + +T3 Code has substantially modified and renamed the implementation, integrated +its markdown renderer, and owns the resulting module going forward. This is not +an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png new file mode 100644 index 000000000000..4696e3fd37d9 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png new file mode 100644 index 000000000000..0d348cb0a2e9 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png new file mode 100644 index 000000000000..7481353a2596 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png new file mode 100644 index 000000000000..da0441b96a48 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png new file mode 100644 index 000000000000..ba07d10d8fa9 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png new file mode 100644 index 000000000000..32a8b227598f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png new file mode 100644 index 000000000000..4b15e51c463e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png new file mode 100644 index 000000000000..7369c907148a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png new file mode 100644 index 000000000000..adc17802ef1d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png new file mode 100644 index 000000000000..90117bf0e5e5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png new file mode 100644 index 000000000000..adc17802ef1d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png new file mode 100644 index 000000000000..0e15d2f96afb Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png new file mode 100644 index 000000000000..19b31f09a1f7 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png new file mode 100644 index 000000000000..06bc23496a10 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png new file mode 100644 index 000000000000..66e899f28ebb Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png new file mode 100644 index 000000000000..e6a14ca533c7 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png new file mode 100644 index 000000000000..4bd7f4a45d5c Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png new file mode 100644 index 000000000000..1efb11a3703a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png new file mode 100644 index 000000000000..98fc3adcb9be Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png new file mode 100644 index 000000000000..3f2c97ea1ac5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png new file mode 100644 index 000000000000..2e63e9c485d5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png new file mode 100644 index 000000000000..9f6be84d09dc Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png new file mode 100644 index 000000000000..827d1feca36e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png new file mode 100644 index 000000000000..b2c4b3dcd89c Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png new file mode 100644 index 000000000000..8742ea19308f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png new file mode 100644 index 000000000000..b31a9473235e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png new file mode 100644 index 000000000000..2fb339d4f973 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png new file mode 100644 index 000000000000..070802b308af Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png new file mode 100644 index 000000000000..8353d6b7e4b2 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png new file mode 100644 index 000000000000..5150250a1b6d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png new file mode 100644 index 000000000000..f5bde9929ff8 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png new file mode 100644 index 000000000000..856e70ac4415 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png new file mode 100644 index 000000000000..cf805c1602c0 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png new file mode 100644 index 000000000000..ae577548b71d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png new file mode 100644 index 000000000000..76e085bf60fa Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png new file mode 100644 index 000000000000..bfbd4298b015 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png new file mode 100644 index 000000000000..93160389d9e7 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png new file mode 100644 index 000000000000..b3de20632f20 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png new file mode 100644 index 000000000000..194bd8e456a9 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png new file mode 100644 index 000000000000..e0951fb7ad2a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png new file mode 100644 index 000000000000..08381b8325b8 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png new file mode 100644 index 000000000000..5c00f0e1887d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png new file mode 100644 index 000000000000..77cbe960e551 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png new file mode 100644 index 000000000000..2bbd936a2d5a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png new file mode 100644 index 000000000000..089b2091c98e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png new file mode 100644 index 000000000000..3a817538888d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png new file mode 100644 index 000000000000..52f29bc3a620 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png new file mode 100644 index 000000000000..adc79f280a48 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png new file mode 100644 index 000000000000..e8bf751f434b Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png new file mode 100644 index 000000000000..006ac67149cf Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png new file mode 100644 index 000000000000..7f3ac3015451 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png new file mode 100644 index 000000000000..cce8c108a6ee Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png new file mode 100644 index 000000000000..252d22784966 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png new file mode 100644 index 000000000000..167002a94db9 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png new file mode 100644 index 000000000000..838d008ff805 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png new file mode 100644 index 000000000000..09f01e445c7f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png new file mode 100644 index 000000000000..64b10efb9aaf Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png new file mode 100644 index 000000000000..4f668ea490f1 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png differ diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts new file mode 100644 index 000000000000..89bce5395c8c --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -0,0 +1,27 @@ +export { markdownFileIconSource } from "./src/markdownFileIcons"; +export { + resolveMarkdownFileIcon, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, + type MarkdownLinkPresentation, +} from "./src/markdownLinks"; +export { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, + type NativeMarkdownDocumentChunk, + type NativeMarkdownTextRun, +} from "./src/nativeMarkdownText"; +export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; +export { + SelectableMarkdownText, + type MarkdownCodeHighlighter, + type MarkdownHighlightedToken, +} from "./src/SelectableMarkdownText"; +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./src/SelectableMarkdownText.types"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h new file mode 100644 index 000000000000..f9c05a19819f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h @@ -0,0 +1,13 @@ +#import +#import + +#ifndef T3MarkdownTextNativeComponent_h +#define T3MarkdownTextNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN +@interface T3MarkdownText : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm new file mode 100644 index 000000000000..6fa61aab17e9 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -0,0 +1,592 @@ +#import "T3MarkdownText.h" +#import "T3MarkdownTextShadowNode.h" +#import "T3MarkdownTextComponentDescriptor.h" +#import "T3MarkdownTextRun.h" +#import +#import + +#import +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +static void T3MarkdownTextApplyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void T3MarkdownTextApplyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges, + NSDictionary *images) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + UIImage *image = images[imageUri]; + if ([imageUri hasPrefix:@"sf:"]) { + NSString *symbolName = [imageUri substringFromIndex:3]; + UIColor *foregroundColor = + [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil] ?: UIColor.labelColor; + image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; + } + attachment.image = image ?: [[UIImage alloc] init]; + 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)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +@protocol T3MarkdownOutsideTapTarget +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; +@end + +@interface T3MarkdownOutsideTapCoordinator : NSObject + +- (instancetype)initWithWindow:(UIWindow *)window; +- (void)addTarget:(id)target; +- (void)removeTarget:(id)target; + +@end + +static const void *T3MarkdownOutsideTapCoordinatorKey = + &T3MarkdownOutsideTapCoordinatorKey; + +@implementation T3MarkdownOutsideTapCoordinator { + __weak UIWindow *_window; + UITapGestureRecognizer *_recognizer; + NSHashTable> *_targets; +} + +- (instancetype)initWithWindow:(UIWindow *)window +{ + if (self = [super init]) { + _window = window; + _targets = [NSHashTable weakObjectsHashTable]; + _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleTap:)]; + _recognizer.cancelsTouchesInView = NO; + _recognizer.delegate = self; + [window addGestureRecognizer:_recognizer]; + } + return self; +} + +- (void)addTarget:(id)target +{ + [_targets addObject:target]; +} + +- (void)removeTarget:(id)target +{ + [_targets removeObject:target]; + if (_targets.count > 0) { + return; + } + + UIWindow *window = _window; + [window removeGestureRecognizer:_recognizer]; + if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +- (void)handleTap:(UITapGestureRecognizer *)sender +{ + UIWindow *window = _window; + if (window == nil) { + return; + } + + UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; + if (hitView == nil) { + return; + } + for (id target in _targets.allObjects) { + [target clearSelectionForOutsideTapWithHitView:hitView]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +@end + +static T3MarkdownOutsideTapCoordinator * +T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); + if (coordinator == nil) { + coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + coordinator, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return coordinator; +} + +@interface T3MarkdownText () + +@end + +@interface T3MarkdownText () +@end + +@implementation T3MarkdownText { + UIView * _view; + UITextView * _textView; + T3MarkdownTextShadowNode::ConcreteState::Shared _state; + __weak UIWindow * _outsideTapWindow; + BOOL _suppressSelectionChange; + NSMutableDictionary * _attachmentImages; + NSMutableSet * _pendingAttachmentUris; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _view = [[UIView alloc] init]; + self.contentView = _view; + self.clipsToBounds = true; + + _textView = [[UITextView alloc] init]; + _attachmentImages = [[NSMutableDictionary alloc] init]; + _pendingAttachmentUris = [[NSMutableSet alloc] init]; + _textView.scrollEnabled = false; + _textView.editable = false; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + _textView.delegate = self; + // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. + _textView.layoutManager.usesFontLeading = NO; + [self addSubview:_textView]; + + const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + longPressGestureRecognizer.delegate = self; + + const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + pressGestureRecognizer.delegate = self; + [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + + [_textView addGestureRecognizer:pressGestureRecognizer]; + [_textView addGestureRecognizer:longPressGestureRecognizer]; + } + + return self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (_outsideTapWindow == self.window) { + return; + } + if (_outsideTapWindow != nil) { + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + } + _outsideTapWindow = self.window; + if (_outsideTapWindow != nil) { + [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; + } +} + +- (void)dealloc +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; +} + +// See RCTParagraphComponentView +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + _outsideTapWindow = nil; + _state.reset(); + + // Reset the frame to zero so that when it properly lays out on the next use + _textView.frame = CGRectZero; + _textView.attributedText = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // _textView's frame is assigned inside drawRect, which only fires when + // state changes. Trigger a redraw whenever the host frame moves out from + // under it (rotation, parent relayout) so the text view resizes and + // onTextLayout re-fires with the new line wrapping. + if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + [self setNeedsDisplay]; + } +} + +- (void)drawRect:(CGRect)rect +{ + if (!_state) { + return; + } + + const auto &props = *std::static_pointer_cast(_props); + + const auto attrString = _state->getData().attributedString; + NSMutableAttributedString *convertedAttrString = + [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; + T3MarkdownTextApplyParagraphStyles( + convertedAttrString, + _state->getData().paragraphStyleRanges); + T3MarkdownTextApplyAttachments( + convertedAttrString, + _state->getData().attachmentRanges, + _attachmentImages); + [self loadAttachmentImages:_state->getData().attachmentRanges]; + + // Setting attributedText clears any active text selection, and re-assigning + // the frame triggers a layout flush that has the same effect. Bail out + // entirely when nothing actually changed so a JS-side state update made in + // response to onSelectionChange doesn't deselect what the user is selecting. + const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; + const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + if (!textChanged && !frameChanged) { + return; + } + if (textChanged) { + // Reassigning attributedText clears any active selection. Save it and + // restore after, while suppressing the synthetic textViewDidChangeSelection + // events the clear-then-restore would otherwise produce — those would + // round-trip to JS and re-trigger this same path, causing a loop. + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = convertedAttrString; + if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + } + if (frameChanged) { + _textView.frame = _view.frame; + } + + __block std::vector lines; + const int maxLines = props.numberOfLines; + [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, + CGRect usedRect, + NSTextContainer * _Nonnull textContainer, + NSRange glyphRange, + BOOL * _Nonnull stop) { + const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; + const auto line = [self->_textView.text substringWithRange:charRange]; + lines.push_back(line.UTF8String); + // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS + // 18, so cap explicitly. + if (maxLines > 0 && lines.size() >= (size_t)maxLines) { + *stop = YES; + } + }]; + + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); + }; +} + +- (void)loadAttachmentImages:(const std::vector &)attachmentRanges +{ + for (const auto &attachmentRange : attachmentRanges) { + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + if ([imageUri hasPrefix:@"sf:"]) { + continue; + } + if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { + continue; + } + + NSURL *url = [NSURL URLWithString:imageUri]; + if (url == nil) { + continue; + } + if (url.isFileURL) { + UIImage *image = [UIImage imageWithContentsOfFile:url.path]; + if (image != nil) { + _attachmentImages[imageUri] = image; + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshDisplayedAttachments]; + }); + } + continue; + } + + [_pendingAttachmentUris addObject:imageUri]; + [[[NSURLSession sharedSession] dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_pendingAttachmentUris removeObject:imageUri]; + if (image != nil) { + self->_attachmentImages[imageUri] = image; + [self refreshDisplayedAttachments]; + } + }); + }] resume]; + } +} + +- (void)refreshDisplayedAttachments +{ + if (!_state || _textView.attributedText == nil) { + return; + } + + NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; + T3MarkdownTextApplyAttachments( + attributedText, + _state->getData().attachmentRanges, + _attachmentImages); + + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = attributedText; + if (savedRange.location != NSNotFound && + NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + [_textView setNeedsDisplay]; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { + _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + + if (oldViewProps.selectable != newViewProps.selectable) { + _textView.selectable = newViewProps.selectable; + } + + if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { + if (@available(iOS 11.0, *)) { + _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + } + } + + if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { + if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; + } + } + + + // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text + // is in will have this background color applied. To apply it just to a particular part of a string, you'd need + // to do Hello. + // This is how the base component works though, so we'll go with it for now. Can change later if we want. + if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { + _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); + } + + [super updateProps:props oldProps:oldProps]; +} + +// See RCTParagraphComponentView +- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + [self setNeedsDisplay]; +} + +// MARK: - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + return YES; +} + +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView +{ + if ([hitView isDescendantOfView:self]) { + return; + } + // Defer past the current event loop turn so any in-flight edit-menu action + // (Copy / Define / Look Up / …) reads the live selection before we clear it. + UITextView *textView = _textView; + dispatch_async(dispatch_get_main_queue(), ^{ + UITextRange *range = textView.selectedTextRange; + if (range != nil && !range.isEmpty) { + textView.selectedTextRange = nil; + } + }); +} + +// MARK: - Touch handling + +- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender +{ + return [sender locationInView:_textView]; +} + +- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location +{ + const auto charIndex = [_textView.layoutManager characterIndexForPoint:location + inTextContainer:_textView.textContainer + fractionOfDistanceBetweenInsertionPoints:nil + ]; + + int currIndex = -1; + for (UIView* child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; + + // This is UTF16 code units!! + currIndex += textChild.text.length; + + if (charIndex <= currIndex) { + return textChild; + } + } + + return nil; +} + +- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onPress]; + } +} + +- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onLongPress]; + } +} + +// MARK: - UITextViewDelegate + +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + if (_suppressSelectionChange) { + return; + } + if (_eventEmitter == nullptr) { + return; + } + + const NSRange selectedRange = textView.selectedRange; + if (selectedRange.location == NSNotFound) { + return; + } + + // Fires on programmatic selection changes too (e.g. the outside-tap clear + // in handleOutsideTap:), so JS will see a synthetic empty-range event then. + std::dynamic_pointer_cast(_eventEmitter) + ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ + static_cast(self.tag), + static_cast(selectedRange.location), + static_cast(selectedRange.location + selectedRange.length), + }); +} + +Class T3MarkdownTextCls(void) +{ + return T3MarkdownText.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h new file mode 100644 index 000000000000..77e21d585100 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm new file mode 100644 index 000000000000..3ca2b1eee5b9 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm @@ -0,0 +1,36 @@ +#import +#import +#import "RCTBridge.h" +#import "Utils.h" + +@interface T3MarkdownTextManager : RCTViewManager +@end + +@implementation T3MarkdownTextManager + +RCT_EXPORT_MODULE(T3MarkdownText) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) +{ +} + +@end + +@interface T3MarkdownTextRunManager : RCTViewManager +@end + +@implementation T3MarkdownTextRunManager + +RCT_EXPORT_MODULE(T3MarkdownTextRun) + +- (UIView *)view +{ + return nil; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h new file mode 100644 index 000000000000..b8b406571106 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -0,0 +1,24 @@ +// This guard prevent this file to be compiled in the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import + +#ifndef T3MarkdownTextRunNativeComponent_h +#define T3MarkdownTextRunNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface T3MarkdownTextRun : RCTViewComponentView + +@property (nonatomic, copy, nullable) NSString *text; + +- (void)onPress; +- (void)onLongPress; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm new file mode 100644 index 000000000000..4549084f03f6 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -0,0 +1,72 @@ +#import "T3MarkdownTextRun.h" +#import "T3MarkdownText.h" +#import "T3MarkdownTextRunComponentDescriptor.h" +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" +#import "Utils.h" + +using namespace facebook::react; + +@interface T3MarkdownTextRun () + +@end + +@implementation T3MarkdownTextRun { + NSString * _text; + RCTBubblingEventBlock _onPress; + RCTBubblingEventBlock _onLongPress; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + return self; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (newViewProps.text != oldViewProps.text) { + NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; + _text = text; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)onPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); + } +} + +- (void)onLongPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); + } +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +Class T3MarkdownTextRunCls(void) +{ + return T3MarkdownTextRun.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h new file mode 100644 index 000000000000..61f9e1a129ee --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextRunShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp new file mode 100644 index 000000000000..a1af619205df --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp @@ -0,0 +1,6 @@ +#include "T3MarkdownTextRunShadowNode.h" + +namespace facebook::react { + +extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h new file mode 100644 index 000000000000..c00bd1f24079 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +extern const char T3MarkdownTextRunComponentName[]; + +using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< + T3MarkdownTextRunComponentName, + T3MarkdownTextRunProps, + T3MarkdownTextRunEventEmitter, + T3MarkdownTextRunState>; +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h new file mode 100644 index 000000000000..99417490a63b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace facebook::react { + +extern const char T3MarkdownTextComponentName[]; + +struct T3MarkdownTextParagraphStyleRange { + size_t location; + size_t length; + Float firstLineHeadIndent; + Float headIndent; + Float paragraphSpacing; +}; + +struct T3MarkdownTextAttachmentRange { + size_t location; + size_t length; + std::string imageUri; +}; + +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; +}; + +class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< +T3MarkdownTextComponentName, +T3MarkdownTextProps, +T3MarkdownTextEventEmitter, +T3MarkdownTextStateReal> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment + ); + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + void layout(LayoutContext layoutContext) override; + + Size measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const override; + +private: + mutable AttributedString _attributedString; + mutable std::vector _paragraphStyleRanges; + mutable std::vector _attachmentRanges; +}; +} // 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 new file mode 100644 index 000000000000..b9abe452fb94 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -0,0 +1,264 @@ +#include "T3MarkdownTextShadowNode.h" +#include "T3MarkdownTextRunShadowNode.h" +#include +#import + +#include +#include + +namespace facebook::react { + +static constexpr Float ParagraphStyleEncodingOffset = 1000; +static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; +static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; + +static void applyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void applyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [[UIImage alloc] init]; + 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)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment +) : ConcreteViewShadowNode(sourceShadowNode, fragment) { +}; + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + baseTextAttributes.backgroundColor = baseProps.backgroundColor; + baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; + + Float fontSizeMultiplier = 1.0; + if (baseTextAttributes.allowFontScaling) { + fontSizeMultiplier = layoutContext.fontSizeMultiplier; + } + + auto baseAttributedString = AttributedString{}; + auto paragraphStyleRanges = std::vector{}; + auto attachmentRanges = std::vector{}; + size_t utf16Offset = 0; + const auto &children = getChildren(); + for (size_t i = 0; i < children.size(); i++) { + const auto child = children[i].get(); + if (auto textViewChild = dynamic_cast(child)) { + auto &props = textViewChild->getConcreteProps(); + auto fragment = AttributedString::Fragment{}; + auto textAttributes = TextAttributes::defaultTextAttributes(); + + textAttributes.allowFontScaling = baseProps.allowFontScaling; + textAttributes.backgroundColor = props.backgroundColor; + textAttributes.fontSize = props.fontSize * fontSizeMultiplier; + textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; + textAttributes.foregroundColor = props.color; + const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; + if (!hasParagraphStyle) { + textAttributes.textShadowColor = props.shadowColor; + textAttributes.textShadowOffset = props.shadowOffset; + textAttributes.textShadowRadius = props.shadowRadius; + } + textAttributes.letterSpacing = props.letterSpacing; + textAttributes.textDecorationColor = props.textDecorationColor; + textAttributes.fontFamily = props.fontFamily; + + if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { + textAttributes.fontStyle = FontStyle::Italic; + } else { + textAttributes.fontStyle = FontStyle::Normal; + } + + if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { + textAttributes.fontWeight = FontWeight::Bold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { + textAttributes.fontWeight = FontWeight::UltraLight; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { + textAttributes.fontWeight = FontWeight::Light; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { + textAttributes.fontWeight = FontWeight::Medium; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { + textAttributes.fontWeight = FontWeight::Semibold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { + textAttributes.fontWeight = FontWeight::Heavy; + } else { + textAttributes.fontWeight = FontWeight::Regular; + } + + if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { + textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; + } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { + textAttributes.textDecorationLineType = TextDecorationLineType::Underline; + } else { + textAttributes.textDecorationLineType = TextDecorationLineType::None; + } + + if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { + textAttributes.textDecorationStyle = TextDecorationStyle::Double; + } + + if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { + textAttributes.alignment = TextAlignment::Left; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { + textAttributes.alignment = TextAlignment::Right; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { + textAttributes.alignment = TextAlignment::Center; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { + textAttributes.alignment = TextAlignment::Justified; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { + textAttributes.alignment = TextAlignment::Natural; + } + + textAttributes.backgroundColor = props.backgroundColor; + + fragment.string = props.text; + fragment.textAttributes = textAttributes; + + NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; + const size_t fragmentLength = fragmentText.length; + if (hasParagraphStyle) { + paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ + utf16Offset, + fragmentLength, + props.shadowOffset.width, + props.shadowOffset.height, + props.shadowRadius - ParagraphStyleEncodingOffset, + }); + } + if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), + }); + } else if ( + props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr( + std::char_traits::length(SkillAttachmentNativeIdPrefix)), + }); + } + utf16Offset += fragmentLength; + baseAttributedString.appendFragment(std::move(fragment)); + } + } + + _attributedString = baseAttributedString; + _paragraphStyleRanges = paragraphStyleRanges; + _attachmentRanges = attachmentRanges; + + NSMutableAttributedString *convertedAttributedString = + [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); + applyAttachments(convertedAttributedString, attachmentRanges); + + const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) + ? layoutConstraints.maximumSize.width + : CGFLOAT_MAX; + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = NO; + NSTextContainer *textContainer = + [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = baseProps.numberOfLines; + if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } else { + textContainer.lineBreakMode = NSLineBreakByClipping; + } + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; + + return { + std::clamp( + static_cast(std::ceil(usedRect.size.width)), + layoutConstraints.minimumSize.width, + layoutConstraints.maximumSize.width), + std::clamp( + static_cast(std::ceil(usedRect.size.height)), + layoutConstraints.minimumSize.height, + layoutConstraints.maximumSize.height), + }; +} + +void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { + ensureUnsealed(); + setStateData(T3MarkdownTextStateReal{ + _attributedString, + _paragraphStyleRanges, + _attachmentRanges, + }); +} +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json new file mode 100644 index 000000000000..d51b6c5d9ff7 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/mobile-markdown-text", + "version": "0.0.0", + "private": true, + "source": "./index.ts", + "files": [ + "assets", + "ios", + "src", + "index.ts", + "LICENSE", + "UPSTREAM.md", + "T3MarkdownText.podspec", + "react-native.config.js" + ], + "main": "./index.ts", + "types": "./index.ts", + "react-native": "./index.ts", + "exports": { + ".": "./index.ts", + "./file-icons": "./src/markdownFileIcons.ts", + "./links": "./src/markdownLinks.ts", + "./markdown": "./src/nativeMarkdownText.ts", + "./primitive": "./src/MarkdownTextPrimitive.tsx", + "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./types": "./src/SelectableMarkdownText.types.ts" + }, + "peerDependencies": { + "expo-asset": "*", + "expo-clipboard": "*", + "expo-haptics": "*", + "expo-symbols": "*", + "react": "*", + "react-native": "*", + "react-native-nitro-markdown": "*" + }, + "codegenConfig": { + "name": "T3MarkdownTextSpec", + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "T3MarkdownText": "T3MarkdownText", + "T3MarkdownTextRun": "T3MarkdownTextRun" + } + }, + "outputDir": { + "ios": "ios/generated" + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-markdown-text/react-native.config.js new file mode 100644 index 000000000000..6b10ea26eec9 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + ios: { + podspecPath: "T3MarkdownText.podspec", + }, + android: null, + }, + }, +}; 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 new file mode 100644 index 000000000000..2c2cc43bc655 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -0,0 +1,136 @@ +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 = 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) { + throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); +} + +const colors = { + astro: "#a631be", + babel: "#d5a910", + bash: "#199f43", + biome: "#1a85d4", + bootstrap: "#693acf", + browserslist: "#d5a910", + bun: "#594c5b", + c: "#1a85d4", + claude: "#d47628", + cpp: "#1a85d4", + css: "#693acf", + database: "#a631be", + default: "#84848a", + docker: "#1a85d4", + eslint: "#693acf", + font: "#84848a", + git: "#ff8c5b", + go: "#1ca1c7", + graphql: "#d32a61", + html: "#d47628", + image: "#d32a61", + javascript: "#d5a910", + json: "#d47628", + markdown: "#199f43", + mcp: "#17a5af", + nextjs: "#84848a", + npm: "#d52c36", + oxc: "#1ca1c7", + postcss: "#d52c36", + prettier: "#17a5af", + python: "#1a85d4", + react: "#1ca1c7", + ruby: "#d52c36", + rust: "#d47628", + sass: "#d32a61", + stylelint: "#84848a", + svelte: "#d52c36", + svg: "#d47628", + svgo: "#199f43", + swift: "#d47628", + table: "#17a5af", + tailwind: "#1ca1c7", + terraform: "#693acf", + text: "#84848a", + typescript: "#1a85d4", + vite: "#a631be", + vscode: "#1a85d4", + vue: "#199f43", + wasm: "#693acf", + webpack: "#1a85d4", + yml: "#d52c36", + zig: "#d47628", + zip: "#d47628", +}; + +const customIcons = { + agents: "t3-file-icon-agents", + claude: "t3-file-icon-claude", + package: "t3-file-icon-package-json", + pnpm: "t3-file-icon-pnpm", + readme: "t3-file-icon-readme", + tsconfig: "t3-file-icon-tsconfig", +}; + +function symbolFromSprite(sprite, id) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = sprite.match( + new RegExp(`]*)>([\\s\\S]*?)<\\/symbol>`), + ); + if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); + return { + body: match[2], + viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", + }; +} + +function renderIcon(token, symbol, color) { + const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); + const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); + NodeFS.writeFileSync( + svgPath, + `${symbol.body}`, + ); + NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { + stdio: "ignore", + }); + NodeFS.rmSync(svgPath); +} + +NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); +NodeFS.mkdirSync(outputDirectory, { recursive: true }); + +const builtInSprite = getBuiltInSpriteSheet("complete"); +const builtInTokens = [...builtInSprite.matchAll(/ match[1]) + .sort(); + +for (const token of builtInTokens) { + renderIcon( + token, + symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), + colors[token] ?? colors.default, + ); +} +for (const [token, symbolId] of Object.entries(customIcons)) { + renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); +} + +const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); +const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens + .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) + .join("\n")}\n} as const satisfies Readonly>;\n`; +NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx new file mode 100644 index 000000000000..ffbc0e2fcb63 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx @@ -0,0 +1,73 @@ +import { SymbolView } from "expo-symbols"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx new file mode 100644 index 000000000000..6ed7fecd2d31 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; +import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; +import { flattenStyles } from "./util"; + +const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ + false, + StyleSheet.create({}), +]); + +const textDefaults: TextProps = { + allowFontScaling: true, + selectable: true, +}; + +const useTextAncestorContext = () => React.useContext(TextAncestorContext); + +/** + * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices + * into the rendered string. `start === end` means the selection was cleared. + */ +export type SelectionChangeEvent = { + nativeEvent: { target: number; start: number; end: number }; +}; + +export type MarkdownTextPrimitiveProps = TextProps & { + uiTextView?: boolean; + /** + * Fired when the native text selection changes. Only fires on iOS when + * `uiTextView` is true. Note: fires on every selection-edge adjustment + * (e.g. dragging a selection handle), so consumers driving expensive work + * off this event should debounce. + */ + onSelectionChange?: (event: SelectionChangeEvent) => void; +}; + +function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { + const [isAncestor, rootStyle] = useTextAncestorContext(); + + // Flatten the styles, and apply the root styles when needed + const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); + const contextValue = React.useMemo<[boolean, ViewStyle]>( + () => [true, flattenedStyle], + [flattenedStyle], + ); + let childPosition = 0; + const nativeChildren = React.Children.toArray(children).map((child) => { + const position = childPosition; + childPosition += 1; + + if (React.isValidElement(child)) { + return child; + } + if (typeof child !== "string" && typeof child !== "number") { + return null; + } + + const text = child.toString(); + return ( + // @ts-expect-error The generated run props do not include inherited Text props. + + ); + }); + + if (!isAncestor) { + return ( + + + {nativeChildren} + + + ); + } + + return <>{nativeChildren}; +} + +function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { + const [isAncestor] = useTextAncestorContext(); + + // Even if the uiTextView prop is set, we can still default to using + // normal selection (i.e. base RN text) if the text doesn't need to be + // selectable + if ((!props.selectable || !props.uiTextView) && !isAncestor) { + return ; + } + return ; +} + +export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { + if (Platform.OS !== "ios") { + return ; + } + return ; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx new file mode 100644 index 000000000000..212c385124ed --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -0,0 +1,702 @@ +import { useEffect, useState } from "react"; +import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { CopyTextButton } from "./CopyTextButton"; +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +type HighlightedCode = ReadonlyArray>; + +const highlightedCodeCache = new Map(); +const highlightedCodePromiseCache = new Map>(); +const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; + +function nodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; +} + +function nodeText(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function documentFor(node: MarkdownNode): MarkdownNode { + return node.type === "document" ? node : { type: "document", children: [node] }; +} + +function SelectableNode(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + return ( + + ); +} + +function codeHighlightCacheKey( + code: string, + language: string | undefined, + theme: "light" | "dark", +): string { + return `${theme}:${language ?? "text"}:${code}`; +} + +function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { + highlightedCodeCache.delete(key); + highlightedCodeCache.set(key, tokens); + + while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { + const oldestKey = highlightedCodeCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + highlightedCodeCache.delete(oldestKey); + } +} + +function loadHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): Promise { + const key = codeHighlightCacheKey(code, language, theme); + const cached = highlightedCodeCache.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = highlightedCodePromiseCache.get(key); + if (pending) { + return pending; + } + + const promise = highlightCode({ code, language, theme }) + .then((tokens) => { + cacheHighlightedCode(key, tokens); + highlightedCodePromiseCache.delete(key); + return tokens; + }) + .catch((error) => { + highlightedCodePromiseCache.delete(key); + throw error; + }); + highlightedCodePromiseCache.set(key, promise); + return promise; +} + +function useHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): HighlightedCode | null { + const key = codeHighlightCacheKey(code, language, theme); + const [highlighted, setHighlighted] = useState<{ + readonly key: string; + readonly tokens: HighlightedCode | null; + }>(() => ({ + key, + tokens: highlightedCodeCache.get(key) ?? null, + })); + + useEffect(() => { + let active = true; + const cached = highlightedCodeCache.get(key); + if (cached) { + cacheHighlightedCode(key, cached); + setHighlighted({ key, tokens: cached }); + return () => { + active = false; + }; + } + + void loadHighlightedCode(code, language, theme, highlightCode) + .then((tokens) => { + if (active) { + setHighlighted({ key, tokens }); + } + }) + .catch(() => { + if (active) { + setHighlighted({ key, tokens: null }); + } + }); + return () => { + active = false; + }; + }, [code, highlightCode, key, language, theme]); + + return highlighted.key === key ? highlighted.tokens : null; +} + +function HighlightedCodeText(props: { + readonly content: string; + readonly highlighted: HighlightedCode | null; + readonly textStyle: NativeMarkdownTextStyle; +}) { + if (!props.highlighted) { + return ( + + {props.content} + + ); + } + const highlighted = props.highlighted; + let sourceOffset = 0; + const keyOccurrences = new Map(); + const keyedLines = highlighted.map((line) => { + const lineStart = sourceOffset; + const tokens = line.map((token) => { + const start = sourceOffset; + sourceOffset += token.content.length; + const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const occurrence = keyOccurrences.get(signature) ?? 0; + keyOccurrences.set(signature, occurrence + 1); + return { key: `${signature}:${occurrence}`, token }; + }); + sourceOffset += 1; + return { + key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + tokens, + }; + }); + + return ( + + {keyedLines.map((line, lineIndex) => ( + + {line.tokens.map(({ key, token }) => ( + + {token.content} + + ))} + {lineIndex + 1 < keyedLines.length ? "\n" : ""} + + ))} + + ); +} + +function NativeCodeBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly compact?: boolean; +}) { + const content = nodeText(props.node).replace(/\n$/, ""); + const colorScheme = useColorScheme(); + const theme = colorScheme === "dark" ? "dark" : "light"; + const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); + const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; + return ( + + + + {languageLabel} + + + + + + + + ); +} + +function collectTableRows(node: MarkdownNode): MarkdownNode[] { + const rows: MarkdownNode[] = []; + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + rows.push(child); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return rows; +} + +function NativeTable(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + const rows = collectTableRows(props.node); + return ( + + + {rows.map((row, rowIndex) => ( + + {(row.children ?? []).map((cell, cellIndex) => ( + + + rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, + )} + textStyle={props.textStyle} + onLinkPress={props.onLinkPress} + /> + + ))} + + ))} + + + ); +} + +function NativeMarkdownImage(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + const href = props.node.href; + if (!href) { + return ( + + ); + } + + return ( + + + {props.node.alt ? ( + + {props.node.alt} + + ) : null} + + ); +} + +function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { + const groups: MarkdownNode[] = []; + let inline: MarkdownNode[] = []; + const flush = () => { + if (inline.length === 0) { + return; + } + groups.push({ type: "paragraph", children: inline }); + inline = []; + }; + + for (const node of nodes) { + if (node.type === "image") { + flush(); + groups.push(node); + } else { + inline.push(node); + } + } + flush(); + return groups; +} + +function NativeMixedParagraph(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + return ( + + {inlineGroups(props.node.children ?? []).map((child, index) => + child.type === "image" ? ( + + ) : ( + + ), + )} + + ); +} + +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; + const start = props.node.start ?? 1; + const nested = props.depth > 0; + return ( + + {(props.node.children ?? []).map((item, index) => { + const taskMarker = item.type === "task_list_item"; + const marker = taskMarker + ? item.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : props.depth % 3 === 1 + ? "◦" + : props.depth % 3 === 2 + ? "▪︎" + : "•"; + const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; + const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; + return ( + + + + {marker} + + + + {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( + + ))} + + + ); + })} + + ); +} + +export function NativeMarkdownBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; + readonly depth?: number; + readonly compact?: boolean; +}) { + const depth = props.depth ?? 0; + switch (props.node.type) { + case "document": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "code_block": + return ( + + ); + case "table": + return ( + + ); + case "image": + return ( + + ); + case "horizontal_rule": + return ( + + ); + case "blockquote": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "list": + return ( + + ); + case "paragraph": + return (props.node.children ?? []).some((child) => child.type === "image") ? ( + + ) : ( + + ); + case "html_block": + case "math_block": + return ( + + + + ); + case "table_head": + case "table_body": + case "table_row": + case "table_cell": + case "list_item": + case "task_list_item": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + default: + 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 new file mode 100644 index 000000000000..c7a5a16d6fd4 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -0,0 +1,210 @@ +import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; + +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { markdownFileIconSource } from "./markdownFileIcons"; +import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +const EXTERNAL_LINK_PREFIX = "◉ "; +const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; +const SKILL_ICON_PLACEHOLDER = "\uFFFC"; +const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; + +function runKeySignature(run: NativeMarkdownTextRun): string { + return [ + run.text, + run.bold, + run.italic, + run.strikethrough, + run.code, + run.href, + run.externalHost, + run.fileIcon, + run.skillName, + run.skillLabel, + run.role, + run.headingLevel, + run.depth, + run.spacing, + run.firstLineHeadIndent, + run.headIndent, + run.paragraphSpacing, + ].join(":"); +} + +function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { + const isFile = run.fileIcon != null; + const isSkill = run.skillName != null; + 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 && !isFile + ? "underline" + : "none"; + + return { + color: isFile + ? textStyle.fileTextColor + : isSkill + ? textStyle.skillTextColor + : run.href + ? textStyle.linkColor + : isHeading + ? textStyle.strongColor + : run.role === "quote-marker" + ? textStyle.quoteMarkerColor + : run.role === "divider" + ? textStyle.dividerColor + : run.role === "code-language" + ? textStyle.mutedColor + : run.role === "list-marker" + ? textStyle.mutedColor + : isCodeBlock + ? textStyle.codeColor + : 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) + : run.role === "list-break" + ? textStyle.fontSize + : isHeading + ? headingFontSize + : run.role === "code-language" + ? 11 + : run.code || isCodeBlock + ? Math.max(12, textStyle.fontSize - 2) + : textStyle.fontSize, + lineHeight: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.lineHeight + (run.spacing ?? 0) + : isHeading + ? Math.max(headingFontSize + 6, 20) + : isCodeBlock + ? 18 + : textStyle.lineHeight, + fontStyle: run.italic ? "italic" : "normal", + fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", + textDecorationLine, + backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined, + ...(hasParagraphStyle + ? { + shadowColor: "transparent", + shadowOffset: { + width: run.firstLineHeadIndent ?? 0, + height: run.headIndent, + }, + shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), + } + : {}), + }; +} + +export function NativeMarkdownSelectableText(props: { + readonly runs: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + const colorScheme = useColorScheme(); + const occurrences = new Map(); + const prefixedExternalLinks = new Set(); + const keyedRuns = props.runs.map((run) => { + const signature = runKeySignature(run); + const occurrence = occurrences.get(signature) ?? 0; + occurrences.set(signature, occurrence + 1); + + let text = run.text; + if (run.fileIcon) { + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; + } else if (run.skillName && run.skillLabel) { + 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}`; + } + + return { key: `${signature}:${occurrence}`, run, text }; + }); + // T3MarkdownText only rebuilds its attributed string during native layout. A + // color-only child update can otherwise leave the previous appearance cached. + const appearanceKey = [ + colorScheme ?? "unspecified", + props.textStyle.color, + props.textStyle.strongColor, + props.textStyle.mutedColor, + props.textStyle.linkColor, + props.textStyle.inlineCodeColor, + props.textStyle.codeColor, + props.textStyle.codeBackgroundColor, + props.textStyle.codeBlockBackgroundColor, + props.textStyle.fileTextColor, + props.textStyle.skillTextColor, + props.textStyle.quoteMarkerColor, + props.textStyle.dividerColor, + ].join(":"); + + return ( + + {keyedRuns.map(({ key, run, text }) => { + const href = run.href; + return ( + { + if (props.onLinkPress) { + props.onLinkPress(href); + } else { + void Linking.openURL(href); + } + } + : undefined + } + > + {text} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx new file mode 100644 index 000000000000..56321ba01ada --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownWithPreservedSoftBreaks, +} from "./nativeMarkdownText"; +import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + preserveSoftBreaks = false, + onLinkPress, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const parsedDocument = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + const document = preserveSoftBreaks + ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) + : parsedDocument; + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [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" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx new file mode 100644 index 000000000000..fcb2472f6488 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -0,0 +1,13 @@ +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts new file mode 100644 index 000000000000..76c1402d3c80 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -0,0 +1,47 @@ +export interface NativeMarkdownTextStyle { + readonly color: string; + readonly strongColor: string; + readonly mutedColor: string; + readonly linkColor: string; + readonly inlineCodeColor: string; + readonly codeColor: string; + readonly codeBackgroundColor: string; + readonly codeBlockBackgroundColor: string; + readonly fileTextColor: string; + readonly skillTextColor: string; + readonly quoteMarkerColor: string; + readonly dividerColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly fontFamily: string; + readonly headingFontFamily: string; + readonly boldFontFamily: string; +} + +export interface MarkdownHighlightedToken { + readonly content: string; + readonly color: string | null; + readonly fontStyle: number | null; +} + +export type MarkdownCodeHighlighter = (input: { + readonly code: string; + readonly language?: string | null; + readonly theme: "light" | "dark"; +}) => Promise>>; + +export interface SelectableMarkdownSkill { + readonly name: string; + readonly displayName?: string | null; +} + +export interface SelectableMarkdownTextProps { + readonly markdown: string; + 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/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts new file mode 100644 index 000000000000..656ad47d252c --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts @@ -0,0 +1,55 @@ +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; +import type { ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; + +interface TargetedEvent { + target: Int32; +} + +interface TextLayoutEvent extends TargetedEvent { + lines: string[]; +} + +/** + * Event fired when text selection changes in the MarkdownTextPrimitive. + * @property target - The view tag identifier + * @property start - The start index of the selected range (0-based) + * @property end - The end index of the selected range (0-based, exclusive) + */ +interface SelectionChangeEvent extends TargetedEvent { + start: Int32; + end: Int32; +} + +type EllipsizeMode = "head" | "middle" | "tail" | "clip"; + +interface NativeProps extends ViewProps { + numberOfLines?: Int32; + allowFontScaling?: WithDefault; + ellipsizeMode?: WithDefault; + selectable?: boolean; + onTextLayout?: BubblingEventHandler; + /** + * Callback fired when the text selection changes. + * + * @example + * ```tsx + * { + * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); + * }} + * > + * Selectable text + * + * ``` + */ + onSelectionChange?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownText", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts new file mode 100644 index 000000000000..7f8fab8d8440 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -0,0 +1,51 @@ +import type { ColorValue, ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Float, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; + +interface TargetedEvent { + target: Int32; +} + +type TextDecorationLine = "none" | "underline" | "line-through"; + +type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; + +export type NativeFontWeight = + | "normal" + | "bold" + | "ultraLight" + | "light" + | "medium" + | "semibold" + | "heavy"; + +type FontStyle = "normal" | "italic"; + +type TextAlign = "auto" | "left" | "right" | "center" | "justify"; + +interface NativeProps extends ViewProps { + text: string; + color?: ColorValue; + fontSize?: Float; + fontStyle?: WithDefault; + fontWeight?: WithDefault; + fontFamily?: string; + letterSpacing?: Float; + lineHeight?: Float; + textDecorationLine?: WithDefault; + textDecorationStyle?: WithDefault; + textDecorationColor?: ColorValue; + textAlign?: WithDefault; + shadowRadius?: WithDefault; + onPress?: BubblingEventHandler; + onLongPress?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownTextRun", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts new file mode 100644 index 000000000000..608fa08c486e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -0,0 +1,62 @@ +import type { ImageSourcePropType } from "react-native"; + +export const MARKDOWN_FILE_ICON_SOURCES = { + agents: require("../assets/file-icons/pierre_agents.png"), + astro: require("../assets/file-icons/pierre_astro.png"), + babel: require("../assets/file-icons/pierre_babel.png"), + bash: require("../assets/file-icons/pierre_bash.png"), + biome: require("../assets/file-icons/pierre_biome.png"), + bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), + browserslist: require("../assets/file-icons/pierre_browserslist.png"), + bun: require("../assets/file-icons/pierre_bun.png"), + c: require("../assets/file-icons/pierre_c.png"), + claude: require("../assets/file-icons/pierre_claude.png"), + cpp: require("../assets/file-icons/pierre_cpp.png"), + css: require("../assets/file-icons/pierre_css.png"), + database: require("../assets/file-icons/pierre_database.png"), + default: require("../assets/file-icons/pierre_default.png"), + docker: require("../assets/file-icons/pierre_docker.png"), + eslint: require("../assets/file-icons/pierre_eslint.png"), + font: require("../assets/file-icons/pierre_font.png"), + git: require("../assets/file-icons/pierre_git.png"), + go: require("../assets/file-icons/pierre_go.png"), + graphql: require("../assets/file-icons/pierre_graphql.png"), + html: require("../assets/file-icons/pierre_html.png"), + image: require("../assets/file-icons/pierre_image.png"), + javascript: require("../assets/file-icons/pierre_javascript.png"), + json: require("../assets/file-icons/pierre_json.png"), + markdown: require("../assets/file-icons/pierre_markdown.png"), + mcp: require("../assets/file-icons/pierre_mcp.png"), + nextjs: require("../assets/file-icons/pierre_nextjs.png"), + npm: require("../assets/file-icons/pierre_npm.png"), + oxc: require("../assets/file-icons/pierre_oxc.png"), + package: require("../assets/file-icons/pierre_package.png"), + pnpm: require("../assets/file-icons/pierre_pnpm.png"), + postcss: require("../assets/file-icons/pierre_postcss.png"), + prettier: require("../assets/file-icons/pierre_prettier.png"), + python: require("../assets/file-icons/pierre_python.png"), + react: require("../assets/file-icons/pierre_react.png"), + readme: require("../assets/file-icons/pierre_readme.png"), + ruby: require("../assets/file-icons/pierre_ruby.png"), + rust: require("../assets/file-icons/pierre_rust.png"), + sass: require("../assets/file-icons/pierre_sass.png"), + stylelint: require("../assets/file-icons/pierre_stylelint.png"), + svelte: require("../assets/file-icons/pierre_svelte.png"), + svg: require("../assets/file-icons/pierre_svg.png"), + svgo: require("../assets/file-icons/pierre_svgo.png"), + swift: require("../assets/file-icons/pierre_swift.png"), + table: require("../assets/file-icons/pierre_table.png"), + tailwind: require("../assets/file-icons/pierre_tailwind.png"), + terraform: require("../assets/file-icons/pierre_terraform.png"), + text: require("../assets/file-icons/pierre_text.png"), + tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), + typescript: require("../assets/file-icons/pierre_typescript.png"), + vite: require("../assets/file-icons/pierre_vite.png"), + vscode: require("../assets/file-icons/pierre_vscode.png"), + vue: require("../assets/file-icons/pierre_vue.png"), + wasm: require("../assets/file-icons/pierre_wasm.png"), + webpack: require("../assets/file-icons/pierre_webpack.png"), + yml: require("../assets/file-icons/pierre_yml.png"), + zig: require("../assets/file-icons/pierre_zig.png"), + zip: require("../assets/file-icons/pierre_zip.png"), +} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts new file mode 100644 index 000000000000..94b08c1de7ee --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts @@ -0,0 +1,8 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownFileIcon } from "./markdownLinks"; +import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { + return MARKDOWN_FILE_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts new file mode 100644 index 000000000000..f13891e3ff80 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -0,0 +1,399 @@ +import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", +] as const; + +export type MarkdownLinkPresentation = + | { + readonly kind: "external"; + readonly href: string; + readonly host: string; + } + | { + readonly kind: "file"; + readonly href: string; + readonly icon: MarkdownFileIcon; + readonly label: string; + readonly path: string; + readonly line?: number; + readonly column?: number; + } + | { + readonly kind: "link"; + readonly href: string | null; + }; + +export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; + +const FILE_ICON_BY_NAME: Readonly> = { + ".babelrc": "babel", + ".babelrc.json": "babel", + ".bash_profile": "bash", + ".bashrc": "bash", + ".browserslistrc": "browserslist", + ".dockerignore": "docker", + ".eslintignore": "eslint", + ".eslintrc": "eslint", + ".eslintrc.cjs": "eslint", + ".eslintrc.js": "eslint", + ".eslintrc.json": "eslint", + ".eslintrc.yaml": "eslint", + ".eslintrc.yml": "eslint", + ".gitattributes": "git", + ".gitignore": "git", + ".gitkeep": "git", + ".gitmodules": "git", + ".oxlintrc.json": "oxc", + ".postcssrc": "postcss", + ".postcssrc.json": "postcss", + ".postcssrc.yaml": "postcss", + ".postcssrc.yml": "postcss", + ".prettierignore": "prettier", + ".prettierrc": "prettier", + ".prettierrc.json": "prettier", + ".prettierrc.cjs": "prettier", + ".prettierrc.js": "prettier", + ".prettierrc.mjs": "prettier", + ".prettierrc.toml": "prettier", + ".prettierrc.yaml": "prettier", + ".prettierrc.yml": "prettier", + ".stylelintignore": "stylelint", + ".stylelintrc": "stylelint", + ".stylelintrc.cjs": "stylelint", + ".stylelintrc.js": "stylelint", + ".stylelintrc.json": "stylelint", + ".stylelintrc.mjs": "stylelint", + ".stylelintrc.yaml": "stylelint", + ".stylelintrc.yml": "stylelint", + ".terraform.lock.hcl": "terraform", + ".zprofile": "bash", + ".zshenv": "bash", + ".zshrc": "bash", + "agents.md": "agents", + "babel.config.js": "babel", + "babel.config.cjs": "babel", + "babel.config.json": "babel", + "babel.config.mjs": "babel", + "biome.json": "biome", + "biome.jsonc": "biome", + "bun.lock": "bun", + "bun.lockb": "bun", + "bunfig.toml": "bun", + "claude.md": "claude", + "compose.yaml": "docker", + "compose.yml": "docker", + "docker-compose.yaml": "docker", + "docker-compose.yml": "docker", + "docker-compose.override.yml": "docker", + dockerfile: "docker", + "eslint.config.js": "eslint", + "eslint.config.cjs": "eslint", + "eslint.config.mjs": "eslint", + "eslint.config.mts": "eslint", + "eslint.config.ts": "eslint", + gemfile: "ruby", + "next.config.js": "nextjs", + "next.config.mjs": "nextjs", + "next.config.mts": "nextjs", + "next.config.ts": "nextjs", + "package.json": "package", + "pnpm-lock.yaml": "pnpm", + "pnpm-workspace.yaml": "pnpm", + "postcss.config.js": "postcss", + "postcss.config.cjs": "postcss", + "postcss.config.mjs": "postcss", + "postcss.config.ts": "postcss", + "prettier.config.js": "prettier", + "prettier.config.cjs": "prettier", + "prettier.config.mjs": "prettier", + rakefile: "ruby", + "readme.md": "readme", + "stylelint.config.js": "stylelint", + "stylelint.config.cjs": "stylelint", + "stylelint.config.mjs": "stylelint", + "svgo.config.js": "svgo", + "svgo.config.cjs": "svgo", + "svgo.config.mjs": "svgo", + "svgo.config.ts": "svgo", + "tailwind.config.js": "tailwind", + "tailwind.config.cjs": "tailwind", + "tailwind.config.mjs": "tailwind", + "tailwind.config.ts": "tailwind", + "tsconfig.json": "tsconfig", + "vite.config.js": "vite", + "vite.config.mjs": "vite", + "vite.config.mts": "vite", + "vite.config.ts": "vite", + "webpack.config.js": "webpack", + "webpack.config.babel.js": "webpack", + "webpack.config.cjs": "webpack", + "webpack.config.mjs": "webpack", + "webpack.config.ts": "webpack", +}; + +const FILE_ICON_BY_EXTENSION: Readonly> = { + "7z": "zip", + astro: "astro", + avif: "image", + "code-workspace": "vscode", + bash: "bash", + bmp: "image", + bz2: "zip", + c: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + css: "css", + csv: "table", + cts: "typescript", + db: "database", + env: "text", + "env.development": "text", + "env.local": "text", + "env.production": "text", + eot: "font", + erb: "ruby", + fish: "bash", + gif: "image", + go: "go", + gql: "graphql", + graphql: "graphql", + gz: "zip", + h: "c", + hh: "cpp", + hpp: "cpp", + hxx: "cpp", + htm: "html", + html: "html", + ico: "image", + icns: "image", + ini: "text", + inl: "cpp", + jar: "zip", + jpeg: "image", + jpg: "image", + js: "javascript", + jsx: "react", + json: "json", + jsonc: "json", + less: "css", + md: "markdown", + mdx: "markdown", + "mdx.tsx": "markdown", + mjs: "javascript", + mts: "typescript", + png: "image", + postcss: "css", + py: "python", + pyi: "python", + pyw: "python", + pyx: "python", + rake: "ruby", + rar: "zip", + rb: "ruby", + rs: "rust", + sass: "sass", + scss: "sass", + sh: "bash", + sql: "database", + sqlite: "database", + sqlite3: "database", + svelte: "svelte", + svg: "svg", + swift: "swift", + tar: "zip", + tf: "terraform", + tfstate: "terraform", + tfvars: "terraform", + tgz: "zip", + ts: "typescript", + tsv: "table", + tsx: "react", + txt: "text", + woff: "font", + woff2: "font", + vue: "vue", + wasm: "wasm", + webp: "image", + yml: "yml", + yaml: "yml", + zig: "zig", + zip: "zip", + zsh: "bash", +}; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") { + return null; + } + const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) + ? parsed.pathname.slice(1) + : parsed.pathname; + 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; + } + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return true; + } + if (POSITION_SUFFIX_PATTERN.test(path)) { + return true; + } + const basename = path.slice(path.lastIndexOf("/") + 1); + return /\.[A-Za-z0-9_-]+$/.test(basename); +} + +function looksLikeFilePath(value: string): boolean { + if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { + return true; + } + if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { + return true; + } + if (value.startsWith("/")) { + return looksLikePosixFilesystemPath(value); + } + if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { + return true; + } + return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); +} + +function fileLabel(value: string): string { + const normalized = value.replaceAll("\\", "/"); + const basename = normalized.slice(normalized.lastIndexOf("/") + 1); + return basename || normalized; +} + +export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { + const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const exactIcon = FILE_ICON_BY_NAME[basename]; + if (exactIcon) return exactIcon; + if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { + return "tsconfig"; + } + const segments = basename.split("."); + for (let index = 1; index < segments.length; index += 1) { + const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; + if (icon) return icon; + } + return "default"; +} + +export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { + const normalized = normalizeDestination(href); + try { + const parsed = new URL(normalized); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return { + kind: "external", + href: parsed.toString(), + host: parsed.hostname, + }; + } + } catch { + // Relative paths and non-URL link destinations are handled below. + } + + 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", + href: normalized, + icon: resolveMarkdownFileIcon(fileTarget.path), + label: fileLabel(targetWithPosition), + path: fileTarget.path, + ...(fileTarget.line ? { line: fileTarget.line } : {}), + ...(fileTarget.column ? { column: fileTarget.column } : {}), + }; + } + + return { + kind: "link", + href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, + }; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts new file mode 100644 index 000000000000..dc84755cbbd7 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -0,0 +1,761 @@ +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; +import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; + +export interface NativeMarkdownTextRun { + readonly text: string; + readonly bold?: boolean; + readonly italic?: boolean; + readonly strikethrough?: boolean; + readonly code?: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly skillName?: string; + readonly skillLabel?: string; + readonly role?: + | "body" + | "heading" + | "list-marker" + | "list-break" + | "quote-marker" + | "code-block" + | "code-language" + | "divider" + | "spacer"; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +export type NativeMarkdownDocumentChunk = + | { + readonly kind: "selectable"; + readonly key: string; + readonly node: MarkdownNode; + } + | { + readonly kind: "rich"; + readonly key: string; + readonly node: MarkdownNode; + }; + +interface RunContext { + readonly bold: boolean; + readonly italic: boolean; + readonly strikethrough: boolean; + readonly code: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly role?: NativeMarkdownTextRun["role"]; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +const EMPTY_CONTEXT: RunContext = { + bold: false, + italic: false, + strikethrough: false, + code: false, +}; + +const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; + +function decodeHtmlEntitiesOnce(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, + (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + if (decimal) { + return String.fromCodePoint(Number.parseInt(decimal, 10)); + } + if (hexadecimal) { + return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + } + switch (entity.toLowerCase()) { + case "&": + return "&"; + case "'": + return "'"; + case ">": + return ">"; + case "<": + return "<"; + case " ": + return "\u00a0"; + case """: + return '"'; + default: + return entity; + } + }, + ); +} + +function decodeHtmlEntities(value: string): string { + let decoded = value; + for (let pass = 0; pass < 2; pass += 1) { + const next = decodeHtmlEntitiesOnce(decoded); + if (next === decoded) { + break; + } + decoded = next; + } + return decoded; +} + +function textNodeContent(value: string): string { + return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); +} + +function inlineHtmlText(value: string): string { + if (/^$/i.test(value.trim())) { + return "\n"; + } + return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); +} + +function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { + return ( + left.bold === right.bold && + left.italic === right.italic && + left.strikethrough === right.strikethrough && + left.code === right.code && + left.href === right.href && + left.externalHost === right.externalHost && + left.fileIcon === right.fileIcon && + left.skillName === right.skillName && + left.skillLabel === right.skillLabel && + left.role === right.role && + left.headingLevel === right.headingLevel && + left.depth === right.depth && + left.spacing === right.spacing && + left.firstLineHeadIndent === right.firstLineHeadIndent && + left.headIndent === right.headIndent && + left.paragraphSpacing === right.paragraphSpacing + ); +} + +function appendRun( + runs: NativeMarkdownTextRun[], + text: string, + context: RunContext, +): NativeMarkdownTextRun[] { + if (text.length === 0) { + return runs; + } + + const run: NativeMarkdownTextRun = { + text, + ...(context.bold ? { bold: true } : {}), + ...(context.italic ? { italic: true } : {}), + ...(context.strikethrough ? { strikethrough: true } : {}), + ...(context.code ? { code: true } : {}), + ...(context.href ? { href: context.href } : {}), + ...(context.externalHost ? { externalHost: context.externalHost } : {}), + ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), + ...(context.role ? { role: context.role } : {}), + ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), + ...(context.depth ? { depth: context.depth } : {}), + ...(context.spacing ? { spacing: context.spacing } : {}), + ...(context.firstLineHeadIndent !== undefined + ? { firstLineHeadIndent: context.firstLineHeadIndent } + : {}), + ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), + ...(context.paragraphSpacing !== undefined + ? { paragraphSpacing: context.paragraphSpacing } + : {}), + }; + const previous = runs.at(-1); + if (previous && sameRunStyle(previous, run)) { + runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; + return runs; + } + + runs.push(run); + return runs; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +function formatSkillLabel(skill: SelectableMarkdownSkill): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return skill.name + .split(/[\s:_-]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function decorateSkillRuns( + runs: ReadonlyArray, + skills: ReadonlyArray, +): ReadonlyArray { + if (skills.length === 0) { + return runs; + } + const skillByName = new Map(skills.map((skill) => [skill.name, skill])); + const decorated: NativeMarkdownTextRun[] = []; + + for (const run of runs) { + if (run.code || run.href || run.fileIcon || run.role === "code-block") { + decorated.push(run); + continue; + } + + let cursor = 0; + let matched = false; + for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const skill = skillByName.get(name); + if (!skill) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + name.length + 1; + if (start > cursor) { + decorated.push({ ...run, text: run.text.slice(cursor, start) }); + } + decorated.push({ + ...run, + text: run.text.slice(start, end), + skillName: name, + skillLabel: formatSkillLabel(skill), + }); + cursor = end; + matched = true; + } + if (!matched) { + decorated.push(run); + } else if (cursor < run.text.length) { + decorated.push({ ...run, text: run.text.slice(cursor) }); + } + } + + return decorated; +} + +function appendChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function nodeTextContent(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeTextContent).join(""); +} + +function appendNode( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "text": + case "math_inline": + return appendRun(runs, textNodeContent(nodeTextContent(node)), context); + case "html_inline": + return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "code_inline": + return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "soft_break": + return appendRun(runs, " ", context); + case "line_break": + return appendRun(runs, "\n", context); + case "bold": + return appendChildren(runs, node, { ...context, bold: true }); + case "italic": + return appendChildren(runs, node, { ...context, italic: true }); + case "strikethrough": + return appendChildren(runs, node, { ...context, strikethrough: true }); + case "link": { + const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); + if (presentation.kind === "file") { + return appendRun(runs, presentation.label, { + ...context, + href: presentation.href, + fileIcon: presentation.icon, + }); + } + if (presentation.kind === "external") { + return appendChildren(runs, node, { + ...context, + href: presentation.href, + externalHost: presentation.host, + }); + } + return appendChildren(runs, node, { + ...context, + ...(presentation.href ? { href: presentation.href } : {}), + }); + } + case "image": + return appendRun(runs, node.alt ?? node.title ?? "", context); + default: + return appendChildren(runs, node, context); + } +} + +export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { + return appendChildren([], node, EMPTY_CONTEXT); +} + +export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode { + const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks); + return { + ...node, + ...(node.type === "soft_break" ? { type: "line_break" as const } : {}), + ...(children ? { children } : {}), + }; +} + +function appendBlockTerminator( + runs: NativeMarkdownTextRun[], + context: RunContext, +): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", context); +} + +function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); +} + +function appendInlineChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function isInlineNode(node: MarkdownNode): boolean { + return ( + node.type === "text" || + node.type === "bold" || + node.type === "italic" || + node.type === "strikethrough" || + node.type === "link" || + node.type === "image" || + node.type === "code_inline" || + node.type === "math_inline" || + node.type === "html_inline" || + node.type === "soft_break" || + node.type === "line_break" + ); +} + +export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { + const blocks: MarkdownNode[] = []; + let inlineNodes: MarkdownNode[] = []; + const flushInlineNodes = () => { + if (inlineNodes.length === 0) { + return; + } + blocks.push({ type: "paragraph", children: inlineNodes }); + inlineNodes = []; + }; + + for (const child of node.children ?? []) { + if (isInlineNode(child)) { + inlineNodes.push(child); + continue; + } + + flushInlineNodes(); + blocks.push(child); + } + flushInlineNodes(); + return blocks; +} + +function appendListItem( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + marker: string, + depth: number, + markerColumnWidth: number, +): NativeMarkdownTextRun[] { + const firstLineHeadIndent = Math.max(0, depth - 1) * 20; + appendRun(runs, `${marker}\t`, { + ...EMPTY_CONTEXT, + role: "list-marker", + depth, + firstLineHeadIndent, + headIndent: firstLineHeadIndent + markerColumnWidth, + paragraphSpacing: 2, + }); + + const children = node.children ?? []; + let wroteInlineContent = false; + for (const child of children) { + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + if (child.type === "list") { + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: 1, + }); + } + appendList(runs, child, depth + 1); + wroteInlineContent = false; + continue; + } + if (isInlineNode(child)) { + appendNode(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + appendDocumentBlock(runs, child, depth); + wroteInlineContent = true; + } + + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: depth === 1 ? 4 : 2, + }); + } + return runs; +} + +function appendList( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const ordered = node.ordered ?? false; + const start = node.start ?? 1; + const children = node.children ?? []; + const markers = children.map((child, index) => + child.type === "task_list_item" + ? child.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : depth % 3 === 2 + ? "◦" + : depth % 3 === 0 + ? "▪︎" + : "•", + ); + const markerWidth = ordered + ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) + : 0; + + for (const [index, child] of children.entries()) { + const marker = markers[index] ?? "•"; + const alignedMarker = + child.type === "task_list_item" + ? marker + : ordered + ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` + : marker; + const markerColumnWidth = + child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; + appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + } + return runs; +} + +function appendQuoteBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + for (const [index, child] of (node.children ?? []).entries()) { + if (index > 0) { + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } + appendRun(runs, "│\u00a0", { + ...EMPTY_CONTEXT, + role: "quote-marker", + depth, + }); + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + } else { + appendDocumentBlock(runs, child, depth); + } + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTableRow( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const cells = node.children ?? []; + for (const [index, cell] of cells.entries()) { + if (index > 0) { + appendRun(runs, "\u00a0│\u00a0", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + } + appendInlineChildren(runs, cell, { + ...EMPTY_CONTEXT, + role: "body", + bold: cell.isHeader ?? false, + depth, + }); + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTable( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + appendTableRow(runs, child, depth); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return runs; +} + +function appendDocumentBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth = 0, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "document": { + const children = node.children ?? []; + for (const [index, child] of children.entries()) { + if (index > 0) { + const previous = children[index - 1]; + appendSpacer( + runs, + child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, + ); + } + appendDocumentBlock(runs, child, depth); + } + return runs; + } + case "heading": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "heading", + headingLevel: node.level ?? 1, + depth, + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "paragraph": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "list": + return appendList(runs, node, depth + 1); + case "blockquote": + return appendQuoteBlock(runs, node, depth); + case "code_block": { + if (node.language) { + appendRun(runs, `${node.language.toUpperCase()}\n`, { + ...EMPTY_CONTEXT, + role: "code-language", + code: true, + depth, + }); + } + const content = nodeTextContent(node); + appendRun(runs, content, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + if (!content.endsWith("\n")) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + } + return runs; + } + case "horizontal_rule": + appendRun(runs, "────────────────────────\n", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + return runs; + case "table": + return appendTable(runs, node, depth); + case "html_block": + appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + case "math_block": + appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + default: + appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } +} + +function containsRichBlock(node: MarkdownNode): boolean { + if ( + node.type === "code_block" || + node.type === "table" || + node.type === "image" || + node.type === "horizontal_rule" || + node.type === "html_block" || + node.type === "math_block" + ) { + return true; + } + return (node.children ?? []).some(containsRichBlock); +} + +export function nativeMarkdownDocumentChunks( + document: MarkdownNode, +): ReadonlyArray { + const chunks: NativeMarkdownDocumentChunk[] = []; + let selectableNodes: MarkdownNode[] = []; + + const flushSelectable = () => { + if (selectableNodes.length === 0) { + return; + } + const first = selectableNodes[0]; + const last = selectableNodes.at(-1); + chunks.push({ + kind: "selectable", + key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, + node: { + type: "document", + children: selectableNodes, + }, + }); + selectableNodes = []; + }; + + for (const [index, child] of (document.children ?? []).entries()) { + if (!containsRichBlock(child)) { + selectableNodes.push(child); + continue; + } + + flushSelectable(); + chunks.push({ + kind: "rich", + key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, + node: child, + }); + } + flushSelectable(); + return chunks; +} + +function topLevelNodes(node: MarkdownNode): ReadonlyArray { + return node.type === "document" ? (node.children ?? []) : [node]; +} + +export function nativeMarkdownChunkSpacing( + previous: NativeMarkdownDocumentChunk | undefined, + current: NativeMarkdownDocumentChunk, +): number { + if (!previous) { + return 0; + } + + const previousLast = topLevelNodes(previous.node).at(-1); + const currentFirst = topLevelNodes(current.node)[0]; + + if (currentFirst?.type === "heading") { + return 20; + } + if (previousLast?.type === "heading") { + return 10; + } + if (previousLast?.type === "list" && currentFirst?.type === "list") { + return 12; + } + return 14; +} + +export function nativeMarkdownDocumentRuns( + node: MarkdownNode, + skills: ReadonlyArray = [], +): ReadonlyArray { + const runs = appendDocumentBlock([], node); + while (runs.length > 0) { + const lastIndex = runs.length - 1; + const last = runs[lastIndex]; + if (!last?.text.endsWith("\n")) { + break; + } + const text = last.text.slice(0, -1); + if (text.length === 0) { + runs.pop(); + } else { + runs[lastIndex] = { ...last, text }; + } + } + return decorateSkillRuns(runs, skills); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/util.ts b/apps/mobile/modules/t3-markdown-text/src/util.ts new file mode 100644 index 000000000000..d9f33d3a2ef5 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/util.ts @@ -0,0 +1,62 @@ +import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; +import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; + +export function flattenStyles(rootStyle: TextStyle, style: StyleProp) { + const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; + return { + ...flattenedStyle, + fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), + backgroundColor: flattenedStyle.backgroundColor + ? flattenedStyle.backgroundColor + : "transparent", + shadowOffset: flattenedStyle.shadowOffset + ? flattenedStyle.shadowOffset + : { width: 0, height: 0 }, + }; +} + +// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop +// value before returning flattened styles. +function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { + switch (fontWeight) { + case "normal": + return "normal"; + case "bold": + return "bold"; + case 100: + case "100": + case "ultralight": + return "ultraLight"; + case 200: + case "200": + return "ultraLight"; + case 300: + case "300": + case "light": + return "light"; + case 400: + case "400": + case "regular": + return "normal"; + case 500: + case "500": + case "medium": + return "medium"; + case 600: + case "600": + case "semibold": + return "semibold"; + case 700: + case "700": + return "semibold"; + case 800: + case "800": + return "bold"; + case 900: + case "900": + case "heavy": + return "heavy"; + default: + return "normal"; + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index f196716599b6..81cdd8417d36 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module { view.setContentWidth(CGFloat(contentWidth)) } + Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in + view.setInitialRowIndex(initialRowIndex) + } + Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") } } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index a4b5e57d667a..f8e080e7609d 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -205,7 +205,7 @@ private struct ReviewDiffNativeStyle { ReviewDiffNativeStyle( rowHeight: metric(payload?.rowHeight, fallback: 24), contentWidth: metric(payload?.contentWidth, fallback: 2800), - changeBarWidth: metric(payload?.changeBarWidth, fallback: 4), + changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), gutterWidth: metric(payload?.gutterWidth, fallback: 50), codePadding: metric(payload?.codePadding, fallback: 8), textVerticalInset: metric(payload?.textVerticalInset, fallback: 3), @@ -243,6 +243,13 @@ private struct ReviewDiffNativeStyle { return CGFloat(value) } + private static func nonNegativeMetric(_ value: Double?, fallback: CGFloat) -> 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 4b08a338e12f..ddf5b2a02500 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,12 +34,13 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", + "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", "test": "vp test run", "typecheck": "tsc --noEmit" }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.3.0", + "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", @@ -48,12 +49,13 @@ "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@react-native-menu/menu": "^2.0.0", - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", + "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", + "@shikijs/langs": "4.2.0", + "@shikijs/themes": "4.2.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", @@ -61,6 +63,7 @@ "diff": "8.0.3", "effect": "catalog:", "expo": "^56.0.0", + "expo-asset": "~56.0.15", "expo-auth-session": "~56.0.12", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", @@ -74,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", @@ -95,15 +99,17 @@ "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.9", + "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": "3.23.0", + "shiki": "4.2.0", "tailwind-merge": "^3.5.0", "uniwind": "^1.6.2" }, "devDependencies": { "@effect/vitest": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "babel-preset-expo": "~56.0.0", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/src/app/+not-found.tsx b/apps/mobile/src/app/+not-found.tsx index 124077b09098..d11155f86025 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 136e141fdcf4..968be6c14a8d 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -5,7 +5,9 @@ import { DMSans_700Bold, useFonts, } from "@expo-google-fonts/dm-sans"; +import { usePathname } from "expo-router"; import Stack from "expo-router/stack"; +import { useCallback } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -14,21 +16,47 @@ 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"; +import { + ClerkSettingsSheetDetentProvider, + useClerkSettingsSheetDetent, +} from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const pathname = usePathname(); + const expandedSettingsRouteIsActive = + pathname === "/settings/archive" || pathname === "/settings/auth"; + + return ( + + + + ); +} + +function AppNavigatorContent() { + const { state } = useWorkspaceState(); + const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); - const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; + const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + useThreadOutboxDrain(); + + const handleSettingsTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); const newTaskScreenOptions = { contentStyle: sheetStyle, @@ -50,10 +78,10 @@ function AppNavigator() { const settingsSheetScreenOptions = { ...connectionSheetScreenOptions, - sheetAllowedDetents: [0.7], + sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; - if (isLoadingSavedConnection) { + if (state.isLoadingConnections) { return ; } @@ -61,7 +89,7 @@ function AppNavigator() { <> @@ -74,7 +102,11 @@ function AppNavigator() { headerShadowVisible: false, }} /> - + diff --git a/apps/mobile/src/app/connections/_layout.tsx b/apps/mobile/src/app/connections/_layout.tsx index 1bd507967fcb..902b53cb15a2 100644 --- a/apps/mobile/src/app/connections/_layout.tsx +++ b/apps/mobile/src/app/connections/_layout.tsx @@ -1,6 +1,6 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; import { useResolveClassNames } from "uniwind"; +import { useThemeColor } from "../../lib/useThemeColor"; export const unstable_settings = { anchor: "index", @@ -8,9 +8,8 @@ export const unstable_settings = { export default function ConnectionsLayout() { const contentStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const connSheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); return ( - + 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 566c038cc249..ca9693dbb198 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 f7a5fd37ac77..7f9962efc986 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,110 +1,119 @@ -import { Stack, useRouter } from "expo-router"; -import { useState } from "react"; -import { Text as RNText, View, useColorScheme } 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 { 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 isDark = useColorScheme() === "dark"; - const iconColor = isDark ? "#f5f5f5" : "#262626"; + 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/_layout.tsx b/apps/mobile/src/app/new/_layout.tsx index 908a49a7f568..2113b13311c3 100644 --- a/apps/mobile/src/app/new/_layout.tsx +++ b/apps/mobile/src/app/new/_layout.tsx @@ -1,8 +1,8 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; import { useResolveClassNames } from "uniwind"; import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; +import { useThemeColor } from "../../lib/useThemeColor"; export const unstable_settings = { anchor: "index", @@ -10,9 +10,8 @@ export const unstable_settings = { export default function NewTaskLayout() { const sheetStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const sheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); return ( diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx index 2861dded1ade..7bf23a4955ad 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 76102d842f49..6e2aa64ce111 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(); + } + }, + [collapse], + ); return ( + + ); } diff --git a/apps/mobile/src/app/settings/archive.tsx b/apps/mobile/src/app/settings/archive.tsx new file mode 100644 index 000000000000..2b900afbbce6 --- /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/auth.tsx b/apps/mobile/src/app/settings/auth.tsx new file mode 100644 index 000000000000..de33207ccda6 --- /dev/null +++ b/apps/mobile/src/app/settings/auth.tsx @@ -0,0 +1,33 @@ +import { useAuth } from "@clerk/expo"; +import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { Redirect, Stack } from "expo-router"; +import { View } from "react-native"; + +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; + +export default function SettingsAuthRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); +} + +function ConfiguredSettingsAuthRouteScreen() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + + return ( + <> + + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + + + ); +} diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b5..8f65c630a54e 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 85d2699c76fc..41799ae7b8b8 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -8,19 +8,27 @@ 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"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; 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"; @@ -31,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 ( @@ -57,6 +65,8 @@ function LocalSettingsRouteScreen() { /> + + @@ -66,9 +76,10 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { push } = useRouter(); + 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"); @@ -85,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(() => { @@ -102,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(() => { @@ -176,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( @@ -232,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; } @@ -265,11 +319,9 @@ function ConfiguredSettingsRouteScreen() { push("/settings/waitlist"); return; } - Alert.alert( - "T3 Cloud unavailable", - "Native T3 Cloud account management is not available in this build.", - ); - }, [isLoaded, isSignedIn, push]); + expandClerkSheet(); + push("/settings/auth"); + }, [expandClerkSheet, isLoaded, isSignedIn, push]); 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} { + if (isLoaded && isSignedIn) { + router.replace("/settings"); + } + }, [isLoaded, isSignedIn, router]), + ); return ( <> @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - void presentAuth()} /> + { + expand(); + router.push("/settings/auth"); + }} + /> ); diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx index eb0ae8e074e9..92e90920e2da 100644 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx @@ -55,6 +55,32 @@ export default function ThreadLayout() { headerStyle: headerBg, }} /> + + ; +} 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 000000000000..b67630dbf065 --- /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 a3587d643ec4..d98a8573e6c6 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/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx new file mode 100644 index 000000000000..0c596e29232f --- /dev/null +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index 7cb93454f882..e054a13f6977 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 ? ( | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx index c36647c90ab6..f06835176fa5 100644 --- a/apps/mobile/src/components/EmptyState.tsx +++ b/apps/mobile/src/components/EmptyState.tsx @@ -19,9 +19,7 @@ export function EmptyState(props: { className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70" onPress={props.onAction} > - - {props.actionLabel} - + {props.actionLabel} ) : null} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 3fb8ba5d917a..d47f924b3988 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/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx index f7cc49c368ef..836a7cffbd7b 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; +import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../lib/useThemeColor"; import { GlassSurface } from "./GlassSurface"; @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ rightSlot, style, }: GlassSafeAreaViewProps) { - const isDarkMode = useColorScheme() === "dark"; const insets = useSafeAreaInsets(); + const headerColor = useThemeColor("--color-header"); + const headerBorderColor = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); const headerPaddingTop = insets.top + 16; const surfaceStyle = { borderRadius: 0, - backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", + backgroundColor: headerColor, borderBottomWidth: 1, - borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", + borderBottomColor: headerBorderColor, } as const; return ( @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ diff --git a/apps/mobile/src/components/LoadingStrip.tsx b/apps/mobile/src/components/LoadingStrip.tsx new file mode 100644 index 000000000000..9c16e1c68e75 --- /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/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx new file mode 100644 index 000000000000..15ea24331b06 --- /dev/null +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -0,0 +1,25 @@ +import { SymbolView } from "expo-symbols"; +import { Image, type ImageStyle, type StyleProp } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; + +export function PierreEntryIcon(props: { + readonly path: string; + readonly kind: "file" | "directory"; + readonly size?: number; + readonly style?: StyleProp; +}) { + const size = props.size ?? 16; + if (props.kind === "directory") { + return ; + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 32297d8d9d22..ba306c5a9fe8 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,65 +1,86 @@ import { SymbolView } from "expo-symbols"; import { useState } from "react"; import { Image, View } from "react-native"; +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 - ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(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/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index d62f8d9a4bff..6c1b1038698d 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) { return ( diff --git a/apps/mobile/src/components/StatusPill.tsx b/apps/mobile/src/components/StatusPill.tsx index 34e6f74b6098..03985463aa85 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 000000000000..b5bda400670c --- /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 000000000000..971fa891106f --- /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 000000000000..5cb17bd5bf71 --- /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 000000000000..6f324c9ff15b --- /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 000000000000..60a660cb4b8f --- /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 000000000000..769632a8fcbe --- /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 000000000000..3698a0a5fc7b --- /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 000000000000..031c152e6599 --- /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 000000000000..276ea3c5c08d --- /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 f06868ed7d9e..5de14ea76fc7 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 7bf29483f1d2..932376e8bce2 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 6d7c247dfad5..2dd3ca03de29 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 a70276236533..18bb93d723ee 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 ce8dfddf3d21..dc275774a500 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 000000000000..be6bfa820fa3 --- /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 44ef38df0ef2..a4e6fc3d6db6 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 346680df8c05..7f97d7c718cb 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 3e49ec1e2579..3281381e0e1d 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 000000000000..d560f8db9faa --- /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 000000000000..3e1934100cdf --- /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 000000000000..6cd530ab37d3 --- /dev/null +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -0,0 +1,144 @@ +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, + 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 000000000000..6146bba20447 --- /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 000000000000..d18cc230c639 --- /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/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx new file mode 100644 index 000000000000..8bd51b8518d8 --- /dev/null +++ b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx @@ -0,0 +1,44 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface ClerkSettingsSheetDetentValue { + collapse: () => void; + expand: () => void; + isExpanded: boolean; +} + +const ClerkSettingsSheetDetentContext = createContext(null); + +interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { + initiallyExpanded: boolean; +} + +export function ClerkSettingsSheetDetentProvider({ + children, + initiallyExpanded, +}: ClerkSettingsSheetDetentProviderProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const collapse = useCallback(() => setIsExpanded(false), []); + const expand = useCallback(() => setIsExpanded(true), []); + const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); + + return ( + {children} + ); +} + +export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { + const value = useContext(ClerkSettingsSheetDetentContext); + if (!value) { + throw new Error( + "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", + ); + } + return value; +} 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 000000000000..2bc62d2a34ee --- /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 5fc3b96fdc8c..c89aeb9249af 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 1528a8fb97f7..4d5b5703329d 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 000000000000..840a3db55680 --- /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 000000000000..05a34cc9835f --- /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 000000000000..8a734c9b9352 --- /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 000000000000..582cb40ffbf9 --- /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 000000000000..4a467a19e4bd --- /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 8eda21b96ce0..8945d148ee94 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 0a3d7c2a5a72..0bd4b7ff1bd0 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 36544cf46cc3..b9ab3aeab057 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 bca1ac21bc75..a77ca628978f 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 0de43d049c5a..2da1fa9157c6 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 3394a519fd6e..eec1e3410e6e 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 000000000000..9642e5f63ae2 --- /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 000000000000..0730f277f3c2 --- /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 d5094d71b8b3..05bf1a8fbccd 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 7a8822eb9db5..93a78fa4f44e 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/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted file mode 100644 index 3356642776a4..000000000000 --- a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { getClerkInstance } from "@clerk/expo"; -import { tokenCache } from "@clerk/expo/token-cache"; -import * as Data from "effect/Data"; -import { useCallback, useRef } from "react"; -import type { TurboModule } from "react-native"; -import { TurboModuleRegistry } from "react-native"; - -const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; - -interface NativeClerkModule extends TurboModule { - readonly getClientToken?: () => Promise; - readonly presentAuth?: (options: { - readonly dismissable: boolean; - readonly mode: "signInOrUp"; - }) => Promise; -} - -interface NativeAuthResult { - readonly cancelled?: boolean; - readonly session?: { - readonly id?: string; - }; - readonly sessionId?: string; -} - -interface ClerkWithNativeSync { - readonly __internal_reloadInitialResources?: () => Promise; - readonly setActive?: (params: { readonly session: string }) => Promise; -} - -const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); - -class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -async function syncNativeSession(sessionId: string): Promise { - const getClientToken = NativeClerk?.getClientToken; - let nativeClientToken: string | null = null; - if (getClientToken) { - try { - nativeClientToken = await getClientToken(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not read native Clerk client token.", - cause, - }); - } - } - if (nativeClientToken) { - const saveToken = tokenCache?.saveToken; - if (saveToken) { - try { - await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not save native Clerk client token.", - cause, - }); - } - } - } - - const clerk = getClerkInstance(); - const clerkWithNativeSync = clerk as ClerkWithNativeSync; - const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; - if (reloadInitialResources) { - try { - await reloadInitialResources(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not reload Clerk resources after native auth.", - cause, - }); - } - } - const setActive = clerkWithNativeSync.setActive; - if (setActive) { - try { - await setActive({ session: sessionId }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not activate native Clerk session.", - cause, - }); - } - } -} - -export function useNativeClerkAuthModal() { - const presentingRef = useRef(false); - - const presentAuth = useCallback(async (): Promise => { - if (presentingRef.current || !NativeClerk?.presentAuth) { - return; - } - - presentingRef.current = true; - const presentNativeAuth = NativeClerk.presentAuth; - try { - // Clerk's iOS AuthView is not inline. It presents this same native modal - // internally; call the presenter directly so Expo Router does not render - // an empty formSheet behind it. - let result: NativeAuthResult | null; - try { - result = await presentNativeAuth({ - dismissable: true, - mode: "signInOrUp", - }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Native Clerk auth presentation failed.", - cause, - }); - } - const sessionId = result?.sessionId ?? result?.session?.id ?? null; - if (sessionId && !result?.cancelled) { - await syncNativeSession(sessionId); - } - } catch (error) { - if (__DEV__) { - console.error("[useNativeClerkAuthModal] presentAuth failed:", error); - } - } finally { - presentingRef.current = false; - } - }, []); - - return { - isAvailable: !!NativeClerk?.presentAuth, - presentAuth, - }; -} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index dd26e2e6ffb8..7b901ec4c660 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 1a03061e23fd..8a692d80729f 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 60d86e0118c8..ce5c6a6419e1 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 000000000000..373b0d3ef035 --- /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 5e17b469de22..0de49ceabf6e 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 000000000000..497af4bfac49 --- /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 000000000000..fc6db479c2ff --- /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 028c46c1ce53..18b6c71a293a 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 f7362900b0cb..910efa7f2565 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 000000000000..bad6b6f17209 --- /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 72abcc8c9565..6c8c957f5410 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 65e7539340d8..975bf7be13d1 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 7bd53f677488..7660a047752b 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 000000000000..ce762ab184e9 --- /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 000000000000..3def77433b29 --- /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 000000000000..b96d6515951f --- /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 000000000000..fba032c0369b --- /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 000000000000..73eca66bf999 --- /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 000000000000..6d03a23d52af --- /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 000000000000..af0ace61fc01 --- /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 000000000000..385d5c139eea --- /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 000000000000..85383514cb56 --- /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 000000000000..28b5822aaa0f --- /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 000000000000..0e7d478c6bdb --- /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 000000000000..9bb341e29098 --- /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 000000000000..6c4c00e16631 --- /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 000000000000..43363115bc84 --- /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 000000000000..4acb67361a8a --- /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 000000000000..3f58f65b46c9 --- /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 000000000000..70ea3e43582b --- /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 000000000000..9757d5fbf91e --- /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 00e4582957c6..7ee5660edf10 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,25 +180,24 @@ function ProjectGroupLabel(props: { return ( - {props.project.title} + {props.title} {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -167,133 +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) => { @@ -305,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 000000000000..cf9b0824aa44 --- /dev/null +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -0,0 +1,223 @@ +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, + 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 000000000000..9f09e894c20e --- /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 000000000000..dd0e2901bba6 --- /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 000000000000..cc5d0dd047ff --- /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 53bf41604774..000000000000 --- a/apps/mobile/src/features/observability/mobileTracing.test.ts +++ /dev/null @@ -1,52 +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 { 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")), - ).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/mobileTracing.ts b/apps/mobile/src/features/observability/mobileTracing.ts deleted file mode 100644 index 32f3d9f94c3b..000000000000 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ /dev/null @@ -1,60 +0,0 @@ -import Constants from "expo-constants"; -import * as Layer from "effect/Layer"; -import type { HttpClient } from "effect/unstable/http"; -import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; - -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; - -export interface MobileTracingConfig { - readonly tracesUrl: string; - readonly tracesDataset: string; - readonly tracesToken: string; -} - -export interface MobileTracingResource { - readonly serviceVersion?: string; - readonly appVariant: string; -} - -export function resolveMobileTracingConfig(): MobileTracingConfig | null { - const config = resolveCloudPublicConfig(); - if (!hasMobileTracingPublicConfig(config)) { - return null; - } - const { tracesUrl, tracesDataset, tracesToken } = config.observability; - return { tracesUrl, tracesDataset, tracesToken }; -} - -export function makeMobileTracingLayer( - config: MobileTracingConfig | null, - resource: MobileTracingResource, -): Layer.Layer { - if (config === null) { - return Layer.empty; - } - - return OtlpTracer.layer({ - url: config.tracesUrl, - headers: { - Authorization: `Bearer ${config.tracesToken}`, - "X-Axiom-Dataset": config.tracesDataset, - }, - resource: { - serviceName: "t3-mobile", - serviceVersion: resource.serviceVersion, - attributes: { - "service.runtime": "react-native", - "service.component": "mobile", - "deployment.environment.name": resource.appVariant, - }, - }, - }).pipe(Layer.provide(OtlpSerialization.layerJson)); -} - -export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { - serviceVersion: Constants.expoConfig?.version, - appVariant: - typeof Constants.expoConfig?.extra?.appVariant === "string" - ? Constants.expoConfig.extra.appVariant - : "unknown", -}); 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 000000000000..b0deb15be8cb --- /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/tracing.ts b/apps/mobile/src/features/observability/tracing.ts new file mode 100644 index 000000000000..eb73abba292b --- /dev/null +++ b/apps/mobile/src/features/observability/tracing.ts @@ -0,0 +1,41 @@ +import Constants from "expo-constants"; +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; + +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; + +export interface TracingConfig { + readonly tracesUrl: string; + readonly tracesDataset: string; + readonly tracesToken: string; +} + +export interface TracingResource { + readonly serviceVersion?: string; + readonly appVariant: string; +} + +export function resolveTracingConfig(): TracingConfig | null { + const config = resolveCloudPublicConfig(); + if (!hasTracingPublicConfig(config)) { + return null; + } + const { tracesUrl, tracesDataset, tracesToken } = config.observability; + return { tracesUrl, tracesDataset, tracesToken }; +} + +export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { + return makeRelayClientTracingLayer(config, { + serviceName: "t3-mobile-relay-client", + serviceVersion: resource.serviceVersion, + runtime: "react-native", + client: `mobile-${resource.appVariant}`, + }); +} + +export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { + serviceVersion: Constants.expoConfig?.version, + appVariant: + typeof Constants.expoConfig?.extra?.appVariant === "string" + ? Constants.expoConfig.extra.appVariant + : "unknown", +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a7423966f672..fa1f635de8de 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 65255c14ff3f..d35c48e8a9bb 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 c82ca71596a8..92203c0ed4e8 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 d747dfc531b2..f60fdfe70e05 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 000000000000..bd25d47a7afe --- /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 000000000000..5e6b1da9bb74 --- /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/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 25e5f9ce4821..b61735f955cf 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -78,4 +78,68 @@ describe("review comment serialization", () => { ); expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" })); }); + + it("parses source-language review comments created by the web file viewer", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Clarify this.", + "```md", + "# Plan", + "- Step one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + filePath: "docs/plan.md", + fenceLanguage: "md", + diff: "# Plan\n- Step one", + }), + }), + ); + }); + + it("keeps fenced examples in comment prose separate from the context fence", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Try this:", + "```ts", + "const value = 1;", + "```", + "Then retry.", + "```diff", + "@@ -0,0 +1,1 @@", + "+one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + text: ["Try this:", "```ts", "const value = 1;", "```", "Then retry."].join("\n"), + diff: "@@ -0,0 +1,1 @@\n+one", + }), + }), + ); + }); + + it("round-trips greater-than signs in review attributes", () => { + const serialized = formatReviewCommentContext( + { ...makeTarget(), sectionTitle: "Changes > 5" }, + "Check this.", + ); + const [comment] = parseReviewInlineComments(serialized); + + expect(serialized).toContain('sectionTitle="Changes > 5"'); + expect(comment?.sectionTitle).toBe("Changes > 5"); + }); }); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 09e1927d179b..5e6e1c3683a9 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -21,6 +21,7 @@ export interface ReviewInlineComment { readonly rangeLabel: string; readonly text: string; readonly diff: string; + readonly fenceLanguage?: string; } export type ReviewCommentMessageSegment = @@ -38,6 +39,7 @@ let currentTarget: ReviewCommentTarget | null = null; const listeners = new Set<() => void>(); const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g; const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; +const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; function emitChange() { listeners.forEach((listener) => listener()); @@ -170,12 +172,17 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string { } function escapeReviewCommentAttribute(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } function unescapeReviewCommentAttribute(value: string): string { return value .replace(/</g, "<") + .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&/g, "&"); } @@ -195,15 +202,19 @@ function readNonNegativeInteger(value: string | undefined): number | null { return Number(value); } -function extractReviewCommentText(rawBody: string): string { - const fenceIndex = rawBody.indexOf("```diff"); - const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody; - return commentBody.trim(); -} - -function extractReviewCommentDiff(rawBody: string): string { - const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/); - return match?.[1]?.trim() ?? ""; +function extractReviewCommentBody(rawBody: string): { + text: string; + language: string; + contents: string; +} { + const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN)); + const match = matches.at(-1); + const fenceIndex = match?.index; + return { + text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(), + language: match?.[2]?.trim() || "diff", + contents: match?.[3] ?? "", + }; } function parseReviewInlineComment( @@ -219,6 +230,7 @@ function parseReviewInlineComment( if (!filePath || !sectionId || startIndex === null || endIndex === null) { return null; } + const body = extractReviewCommentBody(rawBody); return { id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, @@ -228,13 +240,20 @@ function parseReviewInlineComment( startIndex: Math.min(startIndex, endIndex), endIndex: Math.max(startIndex, endIndex), rangeLabel: attributes.rangeLabel?.trim() || "line", - text: extractReviewCommentText(rawBody), - diff: extractReviewCommentDiff(rawBody), + text: body.text, + diff: body.contents, + fenceLanguage: body.language, }; } export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string { const rangeLabel = formatReviewSelectedRangeLabel(target); + const diff = formatReviewSelectedDiff(target); + const longestBacktickRun = Math.max( + 0, + ...Array.from(diff.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); return [ [ "", ].join(""), comment.trim(), - "```diff", - formatReviewSelectedDiff(target), - "```", + `${fence}diff`, + diff, + fence, "", ].join("\n"); } diff --git a/apps/mobile/src/features/review/reviewDiffPreviewState.ts b/apps/mobile/src/features/review/reviewDiffPreviewState.ts deleted file mode 100644 index d0f85cd6d894..000000000000 --- 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 3f2ae01609e4..14ff0276657d 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 43ec2e041823..9cc43d07f2af 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 2622ecbe0500..51b20bb07ff6 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.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index fedf6e10b962..0bc6469426e8 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightReviewFile } from "./shikiReviewHighlighter"; +import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; function makeRenderableFile( input: Partial & Pick, @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { ]); }); }); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 8e254fbb0b3b..008a07619490 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, }); @@ -685,6 +714,25 @@ async function highlightLines( return highlightedLines; } +export async function highlightCodeSnippet(input: { + readonly code: string; + readonly language?: string | null; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const languageHint = input.language?.trim() || "text"; + const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint); + 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 98df26641a98..35f06c263666 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 4c5a1abffb18..87325490990c 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 9d846a5fff2b..ad693dcb4457 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 71643336d543..5d0b0547e6e5 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 e4ac3cc5c8bc..8e9a47a58b51 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 c7418a525339..5cb37cbb0a9d 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 c4686a38b4e8..e5b1f6300734 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 048ce2ac4090..48c87e18dd46 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 0e0e80ef5d9b..29374bdda6da 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 000000000000..871a28d85280 --- /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 000000000000..9f1d032d2641 --- /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 08746eb74e73..8b6fe078088e 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -6,6 +6,8 @@ import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; export type ComposerCommandItem = | { @@ -88,13 +90,13 @@ function PopoverSurface(props: { function itemIcon(item: ComposerCommandItem) { switch (item.type) { - case "path": - return item.kind === "directory" ? ("folder" as const) : ("doc" as const); case "slash-command": case "provider-slash-command": return "terminal" as const; case "skill": return "cube" as const; + case "path": + return null; } } @@ -149,16 +151,28 @@ const CommandRow = memo(function CommandRow(props: { borderBottomColor: "rgba(255,255,255,0.1)", })} > - + {props.item.type === "path" ? ( + + ) : iconName ? ( + + ) : null} {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -177,7 +191,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( {label ? ( {label} @@ -201,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 96fce3e3ccdd..93d929e5961e 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 d59275b0843c..ce24198f5e22 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,19 +1,17 @@ import { Stack, useRouter } from "expo-router"; -import { TextInputWrapper } from "expo-paste-input"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { - InteractionManager, - View, - useColorScheme, - type TextInput as RNTextInput, -} 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 { AppTextInput as TextInput } from "../../components/AppText"; +import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { ComposerToolbarButton, ComposerToolbarRow, @@ -25,28 +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 { useNativePaste } from "../../lib/useNativePaste"; -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; @@ -66,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(); @@ -75,7 +64,8 @@ export function NewTaskDraftScreen(props: { const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; - const promptInputRef = useRef(null); + 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)"; @@ -91,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -118,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) { @@ -176,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", @@ -248,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(() => { @@ -309,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({ @@ -330,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) { @@ -345,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:")) { @@ -410,58 +376,60 @@ export function NewTaskDraftScreen(props: { [flow], ); - const handleNativePaste = useNativePaste((uris) => { - void handleNativePasteImages(uris); - }); - 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,23 +446,19 @@ export function NewTaskDraftScreen(props: { - void handleNativePaste(payload)} + void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - > - - + textStyle={MOBILE_TYPOGRAPHY.composer} + /> 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 c42a7ff34e0f..c9e017772145 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 aa7d033236ed..0050eb923be9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -2,7 +2,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass import type { EnvironmentId, ModelSelection, - OrchestrationThread, + OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, @@ -13,17 +13,14 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; -import { TextInputWrapper } from "expo-paste-input"; import type { ReactNode } from "react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { + ActivityIndicator, Image, Pressable, - TextInput as RNTextInput, useColorScheme, View, - type NativeSyntheticEvent, - type TextInputSelectionChangeEventData, type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; @@ -31,6 +28,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerEditorSelection, +} from "../../components/ComposerEditor"; import { ComposerToolbarButton, ComposerToolbarRow, @@ -41,26 +43,27 @@ 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 { useNativePaste } from "../../lib/useNativePaste"; import { insertRankedSearchResult, normalizeSearchQuery, 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"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */ -export const COMPOSER_COLLAPSED_CHROME = 68; +export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). @@ -68,34 +71,31 @@ export const COMPOSER_COLLAPSED_CHROME = 68; */ export const COMPOSER_EXPANDED_CHROME = 174; -/** - * Height of the expanded-only toolbar below the text surface. - * Used by the feed inset because KeyboardAvoidingLegendList only accounts for - * keyboard height; the floating toolbar remains an additional overlay. - */ -export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60; - export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; 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; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + readonly editorRef?: RefObject; 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 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; } @@ -138,28 +138,73 @@ 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"; - const themePlaceholderColor = useThemeColor("--color-placeholder"); - const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; const foregroundColor = useThemeColor("--color-foreground"); - const inputRef = useRef(null); + const fallbackInputRef = useRef(null); + const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const { onExpandedChange } = props; @@ -167,7 +212,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,20 +227,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } - }, []); + }, [inputRef]); - useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + const handleFocus = useCallback(() => { + setIsFocused(true); + onExpandedChange?.(true); + }, [onExpandedChange]); + + const handleBlur = useCallback(() => { + setIsFocused(false); + onExpandedChange?.(false); + }, [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(() => { @@ -207,38 +265,33 @@ 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"; - - const handleNativePaste = useNativePaste((uris) => { - void props.onNativePasteImages(uris); - }); - // ── Trigger detection ──────────────────────────────────── - const [cursorPosition, setCursorPosition] = useState(0); + const [composerSelection, setComposerSelection] = useState(() => ({ + start: props.draftMessage.length, + end: props.draftMessage.length, + })); - const handleSelectionChange = useCallback( - (event: NativeSyntheticEvent) => { - const { start } = event.nativeEvent.selection; - setCursorPosition(start); - }, - [], - ); + const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + setComposerSelection(selection); + }, []); + useEffect(() => { + const end = props.draftMessage.length; + setComposerSelection((selection) => { + const start = Math.min(selection.start, end); + const selectionEnd = Math.min(selection.end, end); + if (start === selection.start && selectionEnd === selection.end) { + return selection; + } + return { start, end: selectionEnd }; + }); + }, [props.draftMessage.length]); - const composerTrigger = useMemo( - () => detectComposerTrigger(props.draftMessage, cursorPosition), - [cursorPosition, props.draftMessage], - ); + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + return detectComposerTrigger(props.draftMessage, composerSelection.end); + }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, @@ -394,8 +447,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - onSendMessage(); - inputRef.current?.blur(); + void onSendMessage().then(() => { + inputRef.current?.blur(); + }); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { @@ -411,9 +465,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, "", ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - void onUpdateInteractionMode(item.command); + onUpdateInteractionMode(item.command); return; } @@ -434,7 +488,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, replacement, ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], @@ -452,14 +506,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 +544,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 +584,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), }, ], - [ - currentEffort, - currentFastMode, - currentContextWindow, - currentRuntimeMode, - currentInteractionMode, - ], + [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], ); // ── Menu handlers ──────────────────────────────────────── @@ -572,51 +595,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); } } @@ -624,8 +623,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textAlignVertical={isExpanded ? "top" : "center"} - style={ - isExpanded - ? { - minHeight: 80, - maxHeight: 160, - paddingHorizontal: 4, - paddingVertical: 4, - fontSize: 15, - lineHeight: 22, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - : { - maxHeight: 36, - paddingVertical: 6, - fontSize: 15, - lineHeight: 20, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - } - /> - + void props.onNativePasteImages(uris)} + placeholder={props.placeholder} + onFocus={handleFocus} + onBlur={handleBlur} + scrollEnabled={isExpanded} + contentInsetVertical={isExpanded ? 0 : 6} + style={ + isExpanded + ? { + minHeight: 80, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + } + : { + height: 36, + } + } + textStyle={{ + ...MOBILE_TYPOGRAPHY.composer, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + }} + /> {!isExpanded && props.draftAttachments.length > 0 ? ( @@ -749,7 +751,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer justifyContent: "center", }} > - + +{props.draftAttachments.length - 3} @@ -758,11 +760,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} {!isExpanded ? ( showStopAction ? ( - void props.onStopThread()} - /> + ) : ( void props.onStopThread()} + onPress={props.onStopThread} showChevron={false} /> ) : null} @@ -833,8 +831,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 8e6050418fdd..624e8fe14fe4 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,8 +1,9 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { ApprovalRequestId, EnvironmentId, ModelSelection, - OrchestrationThread, + OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, @@ -11,17 +12,20 @@ import type { } from "@t3tools/contracts"; 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 LayoutChangeEvent } from "react-native"; +import { View, type GestureResponderEvent, type LayoutChangeEvent } 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"; import { runOnJS } from "react-native-reanimated"; 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 type { LayoutVariant } from "../../lib/layout"; +import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; import type { PendingApproval, PendingUserInput, @@ -33,17 +37,17 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_EXPANDED_TOOLBAR_CHROME, 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; @@ -54,30 +58,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, @@ -88,7 +92,7 @@ export interface ThreadDetailScreenProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmitUserInput: () => Promise; + readonly onSubmitUserInput: () => Promise; readonly showContent?: boolean; } @@ -200,7 +204,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { onOpenDrawer } = props; const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; + const composerRef = useRef(null); + const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; @@ -211,10 +218,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread 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 expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; - const feedBottomInset = - Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; + const feedBottomInset = resolveThreadFeedBottomInset({ + estimatedOverlayHeight, + measuredOverlayHeight, + gap: 8, + }); + const selectedProviderSkills = useMemo( + () => + props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) + ?.skills ?? [], + [props.serverConfig, selectedInstanceId], + ); const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); @@ -245,20 +261,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); }, []); + const collapseComposer = useCallback(() => { + composerRef.current?.blur(); + }, []); + + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { + feedTouchStartRef.current = { + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }, []); + + const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { + const start = feedTouchStartRef.current; + if (!start) { + return; + } + const deltaX = event.nativeEvent.pageX - start.pageX; + const deltaY = event.nativeEvent.pageY - start.pageY; + if (Math.hypot(deltaX, deltaY) > 8) { + feedTouchStartRef.current = null; + } + }, []); + + const handleFeedTouchEnd = useCallback(() => { + if (feedTouchStartRef.current) { + collapseComposer(); + } + feedTouchStartRef.current = null; + }, [collapseComposer]); + + const handleFeedTouchCancel = useCallback(() => { + feedTouchStartRef.current = null; + }, []); + return ( {showContent ? ( - + + + ) : ( )} @@ -298,10 +361,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 contentTopInset?: number; readonly contentBottomInset?: number; - readonly layoutVariant?: MobileLayoutVariant; + readonly layoutVariant?: LayoutVariant; readonly composerExpanded?: boolean; + 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: ReadonlyArray<{ - readonly id: string; - readonly createdAt: string; - readonly summary: string; - readonly detail: string | null; - readonly status: string | null; - }>, -) { - return activities.map<{ - id: string; - createdAt: string; - summary: string; - detail: string | null; - status: string | null; - }>((activity) => ({ - id: activity.id, - createdAt: activity.createdAt, - summary: activity.summary, - detail: compactActivityDetail(activity.detail), - status: activity.status, - })); + return ( + props.onPressImage(uri)}> + + + ); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; - -function toMarkdownThemeColor(value: ColorValue): string { - return value as string; -} +const MARKDOWN_COLORS = { + light: { + body: "#111111", + strong: "#000000", + link: "#2563eb", + blockquoteBorder: "rgba(0, 0, 0, 0.08)", + 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", + }, + dark: { + body: "#e5e5e5", + strong: "#f5f5f5", + link: "#60a5fa", + blockquoteBorder: "rgba(255, 255, 255, 0.1)", + 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", + }, +} as const; interface MarkdownStyleSets { readonly user: MarkdownStyleSet; @@ -113,6 +172,7 @@ interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; } interface ReviewCommentColors { @@ -124,6 +184,61 @@ interface ReviewCommentColors { readonly codeBackground: ColorValue; } +const failedMarkdownFaviconHosts = new Set(); +const markdownLinkStyles = StyleSheet.create({ + inlineIcon: { + width: 14, + height: 14, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, + favicon: { + borderRadius: 3, + }, + file: { + fontFamily: "DMSans_700Bold", + fontWeight: "700", + }, +}); + +const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { + readonly children: ReactNode; + readonly color: string; + readonly host: string; + readonly href: string; +}) { + const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + + return ( + { + void Linking.openURL(props.href); + }} + style={{ + color: props.color, + fontFamily: "DMSans_400Regular", + textDecorationLine: "none", + }} + > + {!failed ? ( + { + failedMarkdownFaviconHosts.add(props.host); + setFailed(true); + }} + /> + ) : ( + {" ◉ "} + )} + {props.children} + + ); +}); + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -147,35 +262,27 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(): MarkdownStyleSets { - const bodyColor = useThemeColor("--color-md-body"); - const strongColor = useThemeColor("--color-md-strong"); - const linkColor = useThemeColor("--color-md-link"); - const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); - const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); - const codeBg = useThemeColor("--color-md-code-bg"); - const codeText = useThemeColor("--color-md-code-text"); - const hrColor = useThemeColor("--color-md-hr"); - const userBodyColor = useThemeColor("--color-user-bubble-foreground"); - const userCodeBg = useThemeColor("--color-md-user-code-bg"); - const userCodeText = useThemeColor("--color-md-user-code-text"); - const userFenceBg = useThemeColor("--color-md-user-fence-bg"); - const userFenceText = useThemeColor("--color-md-user-fence-text"); +function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { + const colorScheme = useColorScheme(); + const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { - const markdownBodyColor = toMarkdownThemeColor(bodyColor); - const markdownStrongColor = toMarkdownThemeColor(strongColor); - const markdownLinkColor = toMarkdownThemeColor(linkColor); - const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); - const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); - const markdownCodeBg = toMarkdownThemeColor(codeBg); - const markdownCodeText = toMarkdownThemeColor(codeText); - const markdownHrColor = toMarkdownThemeColor(hrColor); - const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); - const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); - const markdownUserCodeText = toMarkdownThemeColor(userCodeText); - const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); - const markdownUserFenceText = toMarkdownThemeColor(userFenceText); + const markdownBodyColor = colors.body; + const markdownStrongColor = colors.strong; + const markdownLinkColor = colors.link; + const markdownBlockquoteBg = colors.blockquoteBackground; + 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; const baseTheme: PartialMarkdownTheme = { colors: { @@ -202,12 +309,12 @@ function useMarkdownStyles(): MarkdownStyleSets { fontSizes: { s: 13, m: 15, - h1: 22, - h2: 19, - h3: 17, - h4: 15, - h5: 15, - h6: 15, + h1: 20, + h2: 18, + h3: 16, + h4: 14, + h5: 14, + h6: 14, }, fontFamilies: { regular: "DMSans_400Regular", @@ -225,8 +332,8 @@ function useMarkdownStyles(): MarkdownStyleSets { const baseStyles: NodeStyleOverrides = { document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 8 }, - list: { marginTop: 4, marginBottom: 4 }, + paragraph: { marginTop: 0, marginBottom: 10 }, + list: { marginTop: 4, marginBottom: 8 }, list_item: { marginTop: 0, marginBottom: 4 }, task_list_item: { marginTop: 0, marginBottom: 4 }, text: { lineHeight: 22 }, @@ -241,20 +348,18 @@ function useMarkdownStyles(): MarkdownStyleSets { textDecorationLine: "underline" as const, }, blockquote: { - borderLeftWidth: 3, + borderLeftWidth: 2, borderLeftColor: markdownBlockquoteBorder, - backgroundColor: markdownBlockquoteBg, - paddingLeft: 12, - paddingVertical: 6, + paddingLeft: 11, + paddingVertical: 2, marginLeft: 0, - marginVertical: 4, - borderRadius: 4, + marginVertical: 10, }, heading: { fontFamily: "DMSans_700Bold", color: markdownStrongColor, - marginTop: 12, - marginBottom: 6, + marginTop: 18, + marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, @@ -263,44 +368,163 @@ function useMarkdownStyles(): MarkdownStyleSets { }, }; - const createCodeRenderers = ( - inlineBackgroundColor: string, + const createMarkdownRenderers = ( inlineTextColor: string, + inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, + preserveSoftBreaks: boolean, ): CustomRenderers => ({ - code_inline: ({ content }) => ( - - {content} - + link: ({ children, href = "" }) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + return ( + onLinkPress(href)} + style={[markdownLinkStyles.file, { color: inlineTextColor }]} + > + + {presentation.label} + + ); + } + if (presentation.kind === "external") { + return ( + + {children} + + ); + } + const linkHref = presentation.href; + return ( + { + void Linking.openURL(linkHref); + } + : undefined + } + style={{ + color: markdownLinkColor, + textDecorationLine: "underline", + }} + > + {children} + + ); + }, + list: ({ node, Renderer, ordered = false, start = 1 }) => ( + + {node.children?.map((child, index) => { + const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; + if (child.type === "task_list_item") { + return ( + + ); + } + return ( + + + {ordered ? `${start + index}.` : "•"} + + + + + + ); + })} + ), - code_block: ({ content }) => ( + code_inline: ({ content }) => { + const value = content ?? ""; + return ( + + {value} + + ); + }, + ...(preserveSoftBreaks + ? { + soft_break: () => {"\n"}, + } + : {}), + code_block: ({ content, language }) => ( - + {language ? ( + + + {language} + + + ) : null} + {content} @@ -333,6 +557,8 @@ function useMarkdownStyles(): MarkdownStyleSets { heading: { ...baseStyles.heading, color: markdownUserBodyColor, + marginTop: 8, + marginBottom: 4, }, link: { color: markdownUserBodyColor, @@ -357,74 +583,137 @@ function useMarkdownStyles(): MarkdownStyleSets { user: { theme: userTheme, styles: userStyles, - renderers: createCodeRenderers( - markdownUserCodeBg, + renderers: createMarkdownRenderers( markdownUserCodeText, + markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, + true, ), + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + inlineCodeColor: markdownUserInlineCodeText, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileTextColor: "#ffffff", + skillTextColor: "#f0abfc", + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + ...MOBILE_TYPOGRAPHY.body, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createCodeRenderers( - markdownCodeBg, + renderers: createMarkdownRenderers( markdownCodeText, + markdownInlineCodeText, markdownCodeBg, markdownCodeText, + false, ), + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + inlineCodeColor: markdownInlineCodeText, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileTextColor: markdownCodeText, + skillTextColor: inlineSkillForeground, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + ...MOBILE_TYPOGRAPHY.body, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, }; - }, [ - blockquoteBg, - blockquoteBorder, - bodyColor, - codeBg, - codeText, - hrColor, - linkColor, - strongColor, - userBodyColor, - userCodeBg, - userCodeText, - userFenceBg, - userFenceText, - ]); + }, [colors, inlineSkillForeground, onLinkPress]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly terminalAssistantMessageIds: ReadonlySet; + readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; + 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; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "turn-fold") { + return ( + props.onToggleTurnFold(entry.turnId)} + hitSlop={4} + className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + > + + {entry.label} + + + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; + const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" @@ -433,35 +722,36 @@ function renderFeedEntry( text={message.text} 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} + /> ); })} - - {timestampLabel} - + + + {timestampLabel} + + {message.text.trim().length > 0 ? ( + + ) : null} + ); } @@ -473,44 +763,51 @@ function renderFeedEntry( } return ( - + {message.text.trim().length > 0 ? ( - - {message.text} - + hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {message.text} + + ) ) : 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="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + onPressImage={props.onPressImage} + /> ); })} - - {timestampLabel} - + {showAssistantMeta ? ( + + + + {timestampLabel} + + + ) : null} ); } @@ -522,7 +819,7 @@ function renderFeedEntry( className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5 opacity-60" style={{ backgroundColor: userBubbleColor }} > - + {entry.queuedMessage.text} {entry.queuedMessage.attachments.length > 0 ? ( @@ -539,69 +836,17 @@ function renderFeedEntry( ); } - const rows = buildActivityRows(entry.activities); - 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 showHeader = hasOverflow; - return ( - - {showHeader ? ( - - - Tool calls ({rows.length}) - - props.onToggleWorkGroup(entry.id)}> - - {isExpanded ? "Show less" : `Show ${hiddenCount} more`} - - - - ) : null} - {visibleRows.map((row, index) => ( - 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", - )} - > - - - - - { - const copyValue = row.detail ?? row.summary; - props.onCopyWorkRow(row.id, copyValue); - }} - style={{ - fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", - }} - > - {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - - {props.copiedRowId === row.id ? ( - - Copied - - ) : null} - - ))} - + props.onToggleWorkGroup(entry.id)} + onToggleRow={props.onToggleWorkRow} + /> ); } @@ -609,10 +854,23 @@ function UserMessageContent(props: { readonly text: string; 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"); if (!hasReviewComment) { + if (hasNativeSelectableMarkdownText()) { + return ( + + ); + } return ( + ) : ( @@ -771,8 +1038,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()} @@ -783,7 +1050,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { {props.comment.text} @@ -795,6 +1062,9 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { }); function buildReviewCommentPatch(comment: ReviewInlineComment): string { + if ((comment.fenceLanguage ?? "diff") !== "diff") { + return ""; + } const diff = comment.diff.trim(); if (!diff) { return ""; @@ -819,63 +1089,300 @@ function compactFileName(filePath: string): string { return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } -const IOS_NAV_BAR_HEIGHT = 44; +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 router = useRouter(); const listRef = useRef(null); 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 [copiedRowId, setCopiedRowId] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [interactionState, setInteractionState] = useState<{ + readonly copiedRowId: string | null; + readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly expandedTurnIds: ReadonlySet; + }>({ + copiedRowId: null, + expandedWorkGroups: {}, + expandedWorkRows: {}, + expandedTurnIds: new Set(), + }); + const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedImage, setExpandedImage] = useState<{ uri: string; headers?: Record; } | 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 = insets.top + IOS_NAV_BAR_HEIGHT; + const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; 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, + }), + [ + copiedRowId, + expandedWorkGroups, + expandedWorkRows, + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + ], + ); + const presentedFeed = useMemo( + () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), + [expandedTurnIds, props.feed, props.latestTurn], + ); + const terminalAssistantMessageIds = useMemo(() => { + const terminalIdsByTurn = new Map(); + for (const entry of props.feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalIdsByTurn.set(entry.message.turnId, entry.message.id); + } + } + return new Set(terminalIdsByTurn.values()); + }, [props.feed]); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? 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(() => { - setCopiedRowId(null); - setExpandedWorkGroups({}); - }, [props.threadId]); + const previous = previousLatestTurnRef.current; + previousLatestTurnRef.current = props.latestTurn; + if (!props.latestTurn || !previous) { + return; + } + if (props.latestTurn.turnId === previous.turnId) { + if (previous.state === "running" && props.latestTurn.state === "interrupted") { + const interruptedTurnId = props.latestTurn.turnId; + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), + })); + } + return; + } + setInteractionState((current) => { + if (!current.expandedTurnIds.has(previous.turnId)) { + return current; + } + const next = new Set(current.expandedTurnIds); + next.delete(previous.turnId); + return { ...current, expandedTurnIds: next }; + }); + }, [props.latestTurn]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } + if (scrollFrameRef.current !== null) { + cancelAnimationFrame(scrollFrameRef.current); + } + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { - void Clipboard.setStringAsync(value); - void Haptics.selectionAsync(); - setCopiedRowId(rowId); + copyTextWithHaptic(value, { + target: "thread-work-row", + feedback: "selection", + }); + setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { - setCopiedRowId((current) => (current === rowId ? null : current)); + setInteractionState((current) => + current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, + ); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((current) => ({ + setInteractionState((current) => ({ + ...current, + expandedWorkGroups: { + ...current.expandedWorkGroups, + [groupId]: !(current.expandedWorkGroups[groupId] ?? false), + }, + })); + }, []); + + const onToggleWorkRow = useCallback((rowId: string) => { + setInteractionState((current) => ({ ...current, - [groupId]: !(current[groupId] ?? false), + expandedWorkRows: { + ...current.expandedWorkRows, + [rowId]: !(current.expandedWorkRows[rowId] ?? false), + }, })); }, []); + const onToggleTurnFold = useCallback((turnId: TurnId) => { + suppressAutoFollowRef.current = true; + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return { ...current, expandedTurnIds: next }; + }); + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { + suppressAutoFollowRef.current = false; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; + }); + }); + }, []); + const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); @@ -883,85 +1390,128 @@ 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, + unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, onPressImage, + onMarkdownLinkPress, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, }), [ copiedRowId, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, onCopyWorkRow, + onMarkdownLinkPress, onPressImage, + onToggleTurnFold, onToggleWorkGroup, - props.bearerToken, - props.httpBaseUrl, + onToggleWorkRow, + props.environmentId, + props.skills, ], ); + if (props.contentPresentation.kind === "loading") { + return ( + + ); + } + + if (props.contentPresentation.kind === "unavailable") { + return ( + + ); + } + if (props.feed.length === 0) { return ( - - - + ); } return ( <> - `${entry.type}:${entry.id}`} - getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type - } - keyboardShouldPersistTaps="handled" - estimatedItemSize={180} - initialScrollAtEnd - maintainScrollAtEnd={{ - on: { layout: true, itemLayout: true, dataChange: true }, - }} - maintainScrollAtEndThreshold={0.1} - safeAreaInsetBottom={insets.bottom} - contentContainerStyle={{ - paddingTop: 12, - paddingHorizontal: horizontalPadding, - }} - /> + + `${entry.type}:${entry.id}`} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="always" + keyboardDismissMode="none" + estimatedItemSize={180} + initialScrollAtEnd + onContentSizeChange={onListContentSizeChange} + onLoad={onListLoad} + onScroll={onListScroll} + scrollEventThrottle={16} + ListHeaderComponent={} + contentContainerStyle={{ + paddingTop: 12, + paddingBottom: bottomContentInset, + paddingHorizontal: horizontalPadding, + }} + /> + ; 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 84ae71dce5cf..9318fb76017a 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 fd73a45b0c8f..f8c916974e54 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, useColorScheme } from "react-native"; +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,24 +83,37 @@ 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 isDark = useColorScheme() === "dark"; const iconColor = String(useThemeColor("--color-icon")); const foregroundColor = String(useThemeColor("--color-foreground")); - const secondaryFg = isDark ? "#a3a3a3" : "#525252"; + 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, @@ -114,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( @@ -132,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) => { @@ -239,7 +276,7 @@ export function ThreadRouteScreen() { if (!selectedThread) { const stillHydrating = - isLoadingSavedConnection || + workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; @@ -266,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, @@ -308,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 58a4032b0ba8..000000000000 --- 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 a6b29fbe4310..e27136702f2a 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 478e2642035f..76e0daf5f0a5 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 a940fcdfcc35..0db7876a7746 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 b13f6a3020c5..16c311bff572 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 1de8eaa688e8..9a8dde3429a2 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,15 +1,18 @@ -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"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; 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"; @@ -20,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; } @@ -46,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"; @@ -66,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; @@ -82,22 +82,20 @@ 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; + readonly selectedProviderSkills: ReadonlyArray; 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; @@ -112,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 }), @@ -144,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( () => @@ -252,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( @@ -283,6 +264,45 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; + 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( @@ -335,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( () => @@ -358,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( () => ({ @@ -441,15 +477,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, runtimeMode, interactionMode, - effort, - fastMode, - contextWindow, expandedProvider, environments, selectedProject, modelOptions, selectedModel, selectedModelOption, + selectedProviderSkills, providerGroups, filteredBranches, reset, @@ -468,9 +502,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { loadBranches, setRuntimeMode, setInteractionMode, - setEffort, - setFastMode, - setContextWindow, + setSelectedModelOptions, setExpandedProvider, }), [ @@ -478,11 +510,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, branchQuery, branchesLoading, - contextWindow, - effort, environments, expandedProvider, - fastMode, filteredBranches, interactionMode, loadBranches, @@ -498,12 +527,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedModelKey, selectedModelOption, + selectedProviderSkills, + setSelectedModelOptions, selectedProject, selectedProjectKey, selectedWorktreePath, setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, @@ -512,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 000000000000..e4ad776e23d4 --- /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 000000000000..707e1a24f0d7 --- /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 000000000000..f179e756fbfb --- /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 000000000000..c806e6dfc462 --- /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 4253cedbc7e7..cf5eb1817a4c 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,12 +1,12 @@ import type { StatusTone } from "../../components/StatusPill"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +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 { @@ -42,12 +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; - } - - const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); - return url.toString(); -} diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 029e1bbdcf69..9531567f4476 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 b341c7b6bd49..09897b6186e1 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 000000000000..a121b70ddb5a --- /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/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts new file mode 100644 index 000000000000..40e00a271f76 --- /dev/null +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; + +const files = new Map(); + +vi.mock("expo-file-system", () => ({ + File: class { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + get exists(): boolean { + return files.has(this.uri) && files.get(this.uri)?.deleted === false; + } + + async base64(): Promise { + const entry = files.get(this.uri); + if (!entry || entry.deleted) { + throw new Error("missing file"); + } + return entry.base64; + } + + delete(): void { + const entry = files.get(this.uri); + if (entry) { + entry.deleted = true; + } + } + }, +})); + +vi.mock("./uuid", () => ({ + uuidv4: () => "attachment-id", +})); + +import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; + +describe("native pasted image cleanup", () => { + beforeEach(() => { + files.clear(); + }); + + it("recognizes only files created in the native composer paste directory", () => { + expect( + isOwnedPastedImageUri( + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png", + ), + ).toBe(true); + expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false); + expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false); + }); + + it("converts owned files to data-backed previews and deletes the source", async () => { + const uri = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png"; + files.set(uri, { base64: "aGVsbG8=", deleted: false }); + + const attachments = await convertPastedImagesToAttachments({ + uris: [uri], + existingCount: 0, + }); + + expect(attachments).toEqual([ + expect.objectContaining({ + dataUrl: "data:image/png;base64,aGVsbG8=", + previewUri: "data:image/png;base64,aGVsbG8=", + }), + ]); + expect(files.get(uri)?.deleted).toBe(true); + }); + + it("deletes rejected and overflow owned files without deleting user-owned files", async () => { + const rejected = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png"; + const overflow = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png"; + const userOwned = "file:///private/var/mobile/photos/library.png"; + files.set(rejected, { base64: "", deleted: false }); + files.set(overflow, { base64: "aGVsbG8=", deleted: false }); + files.set(userOwned, { base64: "aGVsbG8=", deleted: false }); + + await convertPastedImagesToAttachments({ + uris: [rejected, overflow, userOwned], + existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1, + }); + + expect(files.get(rejected)?.deleted).toBe(true); + expect(files.get(overflow)?.deleted).toBe(true); + expect(files.get(userOwned)?.deleted).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 871982442e6a..13b53af724e2 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string { } } +export function isOwnedPastedImageUri(uri: string): boolean { + try { + const url = new URL(uri); + if (url.protocol !== "file:") { + return false; + } + const segments = url.pathname.split("/").filter(Boolean); + return ( + segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true + ); + } catch { + return false; + } +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; }): Promise> { const { File } = await import("expo-file-system"); const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; - const uris = input.uris.slice(0, Math.max(0, remainingSlots)); const results: DraftComposerImageAttachment[] = []; - for (const uri of uris) { + for (const [index, uri] of input.uris.entries()) { + const ownedTemporaryFile = isOwnedPastedImageUri(uri); try { + if (index >= Math.max(0, remainingSlots)) { + continue; + } const file = new File(uri); const base64 = await file.base64(); const sizeBytes = estimateBase64ByteSize(base64); @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { mimeType, sizeBytes, dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: uri, + previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); } catch (error) { console.warn("Failed to read pasted image", uri, error); + } finally { + if (ownedTemporaryFile) { + try { + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("Failed to remove temporary pasted image", uri, error); + } + } } } diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 68813b0b3b1b..f1f30b298b66 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 aa92c6f5d582..839bc70e6d95 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 new file mode 100644 index 000000000000..236fb44cd6b0 --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + impactAsync: vi.fn(), + selectionAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: mocks.setStringAsync, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { + Light: "light", + }, + impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, +})); + +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", () => { + copyTextWithHaptic("trace-123"); + + 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 new file mode 100644 index 000000000000..1cc8c94eef7a --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -0,0 +1,70 @@ +import * as Schema from "effect/Schema"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; + +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 0ae284e463fd..2ae4314fdba2 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 new file mode 100644 index 000000000000..ff57287b7412 --- /dev/null +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkPresentation", () => { + it("extracts external link hosts", () => { + expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ + kind: "external", + href: "https://example.com/docs?q=1", + host: "example.com", + }); + }); + + it("renders file URLs as basename pills with positions", () => { + expect( + 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", + }); + }); + + it("uses the Pierre complete icon mappings", () => { + expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({ + kind: "file", + icon: "react", + }); + expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({ + kind: "file", + icon: "vite", + }); + expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({ + kind: "file", + icon: "docker", + }); + expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({ + kind: "file", + icon: "pnpm", + }); + }); + + it("does not style app routes as file links", () => { + expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ + kind: "link", + href: null, + }); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts new file mode 100644 index 000000000000..9a71640b45ad --- /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 778e5bfb5b52..e21682414d7d 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 new file mode 100644 index 000000000000..6e41f2243a93 --- /dev/null +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -0,0 +1,754 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, + nativeMarkdownWithPreservedSoftBreaks, +} from "@t3tools/mobile-markdown-text/markdown"; + +describe("nativeMarkdownTextRuns", () => { + it("preserves inline emphasis and code styles", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "plain " }, + { type: "bold", children: [{ type: "text", content: "bold" }] }, + { type: "text", content: " " }, + { type: "code_inline", content: "const value = 1" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "plain " }, + { text: "bold", bold: true }, + { text: " " }, + { text: "const value = 1", code: true }, + ]); + }); + + it("normalizes external and file links for native presentation", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "text", content: "Docs" }], + }, + { type: "text", content: " " }, + { + type: "link", + href: "file:///repo/README.md#L12", + children: [{ type: "text", content: "ignored label" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { + text: "Docs", + href: "https://example.com/docs", + externalHost: "example.com", + }, + { text: " " }, + { + text: "README.md:12", + href: "file:///repo/README.md#L12", + fileIcon: "readme", + }, + ]); + }); + + it("keeps hard breaks and collapses soft breaks", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + { type: "line_break" }, + { type: "text", content: "third" }, + ], + }; + + 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", + children: [ + { type: "text", content: "Less than: < " }, + { type: "html_inline", content: "" }, + { type: "text", content: "⌘" }, + { type: "html_inline", content: "" }, + { type: "html_inline", content: "
" }, + { type: "html_inline", content: "highlighted" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); + }); + + it("normalizes double-encoded entities and inline tags emitted as text", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + content: + "Keyboard: + K; Less than: &lt;; Greater than: &gt;", + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, + ]); + }); + + it("reads inline content from nested text nodes", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + children: [{ type: "text", content: "Plain text" }], + }, + { type: "text", content: " and " }, + { + type: "code_inline", + children: [{ type: "text", content: "inline code" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Plain text and " }, + { text: "inline code", code: true }, + ]); + }); +}); + +describe("nativeMarkdownDocumentRuns", () => { + it("decorates known skill references as selectable skill links", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }, + { text: " for this.", role: "body" }, + ]); + }); + + it("leaves unknown skill-like text unchanged", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $unknown for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ + { text: "Use $unknown for this.", role: "body" }, + ]); + }); + + it("keeps headings, paragraphs, and lists in one continuous document", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Header One" }], + }, + { + type: "paragraph", + children: [ + { type: "text", content: "A paragraph with " }, + { type: "bold", children: [{ type: "text", content: "bold text" }] }, + { type: "text", content: "." }, + ], + }, + { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "First item" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Second item" }], + }, + ], + }, + ], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe( + "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", + ); + expect(runs).toContainEqual({ + text: "Header One\n", + role: "heading", + headingLevel: 1, + }); + expect(runs).toContainEqual({ + text: "bold text", + bold: true, + role: "body", + }); + expect(runs).toContainEqual({ + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }); + }); + + it("uses distinct section, heading-content, and body spacing", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Intro" }], + }, + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "First paragraph" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "Second paragraph" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .filter((run) => run.role === "spacer") + .map((run) => run.spacing), + ).toEqual([20, 10, 12]); + }); + + it("renders tight list items whose inline nodes are direct children", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "bold", + children: [{ type: "text", content: "Finding:" }], + }, + { type: "text", content: " details with " }, + { type: "code_inline", content: "inline code" }, + { type: "text", content: "." }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node)).toEqual([ + { + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }, + { text: "Finding:", bold: true, role: "body", depth: 1 }, + { text: " details with ", role: "body", depth: 1 }, + { text: "inline code", code: true, role: "body", depth: 1 }, + { text: ".", role: "body", depth: 1 }, + ]); + }); + + it("includes quotes and fenced code in the same selectable string", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Read this" }], + }, + ], + }, + { + type: "code_block", + language: "ts", + content: "const answer = 42;", + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); + expect(runs).toContainEqual({ + text: "const answer = 42;", + code: true, + role: "code-block", + }); + }); + + it("reads fenced code content from child text nodes", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .map((run) => run.text) + .join(""), + ).toBe("BASH\npnpm install"); + }); +}); + +describe("nativeMarkdownListItemBlocks", () => { + it("groups consecutive inline nodes into one paragraph block", () => { + const item: MarkdownNode = { + type: "list_item", + children: [ + { type: "text", content: "Finding: " }, + { type: "bold", children: [{ type: "text", content: "important" }] }, + { type: "text", content: " details." }, + { + type: "list", + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + { type: "text", content: "Trailing prose." }, + ], + }; + + expect(nativeMarkdownListItemBlocks(item)).toEqual([ + { + type: "paragraph", + children: item.children?.slice(0, 3), + }, + item.children?.[3], + { + type: "paragraph", + children: [item.children?.[4]], + }, + ]); + }); +}); + +describe("nativeMarkdownDocumentChunks", () => { + it("keeps headings and plain lists in one selectable document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Tasks" }], + }, + { + type: "list", + children: [ + { + type: "task_list_item", + checked: true, + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Completed" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Parent" }], + }, + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect( + nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) + .map((run) => run.text) + .join(""), + ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); + }); + + it("aligns ordered markers while keeping the list in one selectable string", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + ordered: true, + start: 9, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Ninth" }], + }, + { + type: "list_item", + children: [{ type: "text", content: "Tenth" }], + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(document) + .map((run) => run.text) + .join(""), + ).toBe("\u20079.\tNinth\n10.\tTenth"); + }); + + it("keeps prose selectable while exposing rich AST blocks", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + beg: 0, + end: 9, + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + beg: 11, + end: 35, + children: [{ type: "text", content: "pnpm install\n" }], + }, + { + type: "paragraph", + beg: 37, + end: 42, + children: [{ type: "text", content: "Done." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:code_block:11:35", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps a list containing fenced code as one rich AST container", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + beg: 0, + end: 45, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentChunks(document)).toEqual([ + { + kind: "rich", + key: "rich:list:0:45", + node: document.children?.[0], + }, + ]); + }); + + it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Before" }], + }, + { type: "horizontal_rule" }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:horizontal_rule:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps offset-free structural lists isolated without promoting the whole document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "list", + ordered: true, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:list:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("never collapses a rich subtree into a second markdown parsing pass", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "blockquote", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { type: "text", content: "Run this" }, + { + type: "code_block", + language: "sh", + children: [{ type: "text", content: "vp check\n" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); + expect(chunks[1]).toMatchObject({ + kind: "rich", + node: { type: "blockquote" }, + }); + }); + + it("keeps a plain list in one selectable native text container", () => { + const list: MarkdownNode = { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "First" }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks({ + type: "document", + children: [list], + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ + kind: "selectable", + node: { type: "document", children: [list] }, + }); + }); + + it("separates sections more than related rich blocks", () => { + const headingChunk = { + kind: "selectable" as const, + key: "heading", + node: { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + ], + } satisfies MarkdownNode, + }; + const firstList = { + kind: "rich" as const, + key: "list-1", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + const secondList = { + kind: "rich" as const, + key: "list-2", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + + expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); + expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); + expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); + expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts new file mode 100644 index 000000000000..5a69cbdd43bd --- /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 000000000000..10e6378bc000 --- /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 000000000000..d7f99a3dab78 --- /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 000000000000..ae1954989621 --- /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 191afe03c181..8cea5df2307e 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 5238411a643c..bf4c2f3fccd5 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 000000000000..773de9d84f7f --- /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 bf49a20ac410..3a33e2ee0f92 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 ce37a41e8ab3..51a4885562c0 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 83ff2db57480..084f9430d084 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 2f9e4962c1ad..114648277b92 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 94354df744e0..f5d8f4bdf119 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, + MessageId, ProjectId, ProviderInstanceId, ThreadId, @@ -10,7 +11,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { buildThreadFeed } from "./threadActivity"; +import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; function makeActivity( input: Partial & @@ -48,7 +49,7 @@ function makeThread( } describe("buildThreadFeed", () => { - it("includes runtime warnings from the latest turn", () => { + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread, [], null); - const group = feed[0]; - - expect(group).toMatchObject({ - type: "activity-group", - }); - if (!group || group.type !== "activity-group") { - return; - } - - expect(group.activities).toEqual([ + expect(feed).toMatchObject([ { - id: "activity-latest", - createdAt: "2026-04-01T00:00:03.000Z", - summary: "Runtime warning", - detail: null, - status: null, + type: "activity-group", + turnId: "turn-old", + activities: [{ id: "activity-old", turnId: "turn-old" }], + }, + { + type: "activity-group", + turnId: "turn-latest", + activities: [{ id: "activity-latest", turnId: "turn-latest" }], }, ]); }); @@ -163,10 +158,252 @@ describe("buildThreadFeed", () => { { id: "tool-completed", createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", summary: "Run tests", detail: "bun run test", - status: null, + 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, [], null)[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({ + id: ThreadId.make("thread-3"), + projectId: ProjectId.make("project-1"), + title: "Folded work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:18.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "I am checking.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Done.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:18.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Read files", + itemType: "file_read", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + 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({ + type: "turn-fold", + label: "Worked for 17s", + expanded: false, + }); + + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.id)).toEqual([ + "turn-fold:turn-1", + "assistant-commentary", + "tool-completed", + "assistant-final", + ]); + }); + + it("measures a steer-superseded turn from its user boundary through trailing work", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const thread = makeThread({ + id: ThreadId.make("thread-steered"), + projectId: ProjectId.make("project-1"), + title: "Steered work", + latestTurn: { + turnId: secondTurnId, + state: "running", + requestedAt: "2026-04-01T00:00:14.000Z", + startedAt: "2026-04-01T00:00:14.000Z", + completedAt: null, + assistantMessageId: MessageId.make("assistant-next"), + }, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "Do it once more.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "Kicking off call 1.", + turnId: firstTurnId, + streaming: false, + createdAt: "2026-04-01T00:00:09.000Z", + updatedAt: "2026-04-01T00:00:09.000Z", + }, + { + id: MessageId.make("user-2"), + role: "user", + text: "Actually do 15.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:14.000Z", + updatedAt: "2026-04-01T00:00:14.000Z", + }, + { + id: MessageId.make("assistant-next"), + role: "assistant", + text: "One down - adjusting.", + turnId: secondTurnId, + streaming: true, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:17.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("work-1"), + kind: "tool.completed", + tone: "tool", + summary: "Ran command", + createdAt: "2026-04-01T00:00:12.000Z", + turnId: firstTurnId, + payload: { + title: "Ran command", + itemType: "command_execution", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ + turnId: firstTurnId, + label: "Worked for 12s", + }); + }); + + it("keeps an active turn expanded and classifies error-shaped tool output", () => { + const turnId = TurnId.make("turn-running"); + const thread = makeThread({ + id: ThreadId.make("thread-4"), + projectId: ProjectId.make("project-1"), + title: "Running work", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("tool-failed"), + kind: "tool.completed", + tone: "tool", + summary: "Run command", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Run command", + itemType: "command_execution", + detail: "zsh: command not found: nope", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ status: "failure" }], + }); + }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6ff27cadfee7..bef46e46e6e2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,17 +1,16 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - CommandId, - EnvironmentId, MessageId, + OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, - TurnId, ToolLifecycleItemType, - ThreadId, + TurnId, UserInputQuestion, } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import type { QueuedThreadMessage } from "../state/thread-outbox-model"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -33,27 +32,37 @@ 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; + readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly status: 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; } +type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; + interface WorkLogEntry { id: string; createdAt: string; + turnId: TurnId | null; label: string; detail?: string; command?: string; @@ -63,6 +72,8 @@ interface WorkLogEntry { toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; + toolLifecycleStatus?: WorkLogToolLifecycleStatus; + toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -88,6 +99,7 @@ type RawThreadFeedEntry = readonly type: "activity"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; @@ -97,9 +109,23 @@ export type ThreadFeedEntry = readonly type: "activity-group"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activities: ReadonlyArray; + } + | { + readonly type: "turn-fold"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId; + readonly label: string; + readonly expanded: boolean; }; +export type ThreadFeedLatestTurn = Pick< + OrchestrationLatestTurn, + "turnId" | "state" | "startedAt" | "completedAt" +>; + function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -202,22 +228,18 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): WorkLogEntry[] { +): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (latestTurnId && activity.turnId !== latestTurnId) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started" || activity.kind === "task.completed") continue; + if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; 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 { @@ -240,16 +262,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = + isTaskActivity && + !taskSummary && + typeof payload?.detail === "string" && + payload.detail.length > 0 + ? payload.detail + : null; + const taskLabel = taskSummary || taskDetailAsLabel; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + turnId: activity.turnId, + label: taskLabel || activity.summary, + tone: + activity.kind === "task.progress" + ? "thinking" + : activity.tone === "approval" + ? "info" + : activity.tone, activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if ( + !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ) { const detail = stripTrailingExitCode(payload.detail).output; if (detail) { entry.detail = detail; @@ -267,12 +313,25 @@ 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; } if (requestKind) { entry.requestKind = requestKind; } + let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.completed") { + toolLifecycleStatus = "completed"; + } + if (toolLifecycleStatus) { + entry.toolLifecycleStatus = toolLifecycleStatus; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -323,6 +382,8 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; 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, @@ -334,6 +395,8 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } @@ -365,6 +428,124 @@ function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { + return true; + } + if (entry.command !== undefined && entry.command.trim().length > 0) { + return true; + } + if (entry.requestKind !== undefined) { + return true; + } + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); +} + +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { + if (entry.tone === "error") { + return true; + } + if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { + return true; + } + if (!workLogEntryIsToolLike(entry)) { + return false; + } + return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); +} + +function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { + if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { + return false; + } + if (entry.tone === "thinking") { + return false; + } + return ( + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" && + entry.toolLifecycleStatus !== "failed" && + entry.toolLifecycleStatus !== "declined" + ); +} + +function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (!workLogEntryIsToolLike(entry)) { + return null; + } + if (workEntryIndicatesToolFailure(entry)) { + return "failure"; + } + if (workEntryIndicatesToolSuccess(entry)) { + return "success"; + } + 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 { @@ -592,6 +773,22 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractWorkLogToolLifecycleStatus( + payload: Record | null, +): WorkLogToolLifecycleStatus | undefined { + const status = payload?.status; + if ( + status === "inProgress" || + status === "completed" || + status === "failed" || + status === "declined" || + status === "stopped" + ) { + return status; + } + return undefined; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -743,7 +940,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } const previous = grouped.at(-1); - if (previous?.type === "activity-group") { + if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { grouped[grouped.length - 1] = { ...previous, activities: [...previous.activities, entry.activity], @@ -755,6 +952,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th type: "activity-group", id: entry.id, createdAt: entry.createdAt, + turnId: entry.turnId, activities: [entry.activity], }); } @@ -762,6 +960,179 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th return grouped; } +function computeElapsedMs(startIso: string, endIso: string): number | null { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + return Math.max(0, end - start); +} + +function maxIsoTimestamp(a: string | null, b: string | null): string | null { + if (a === null) return b; + if (b === null) return a; + const aMs = Date.parse(a); + const bMs = Date.parse(b); + if (!Number.isFinite(aMs)) return b; + if (!Number.isFinite(bMs)) return a; + return bMs > aMs ? b : a; +} + +function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { + if (!latestTurn) { + return null; + } + const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; + return settled ? null : latestTurn.turnId; +} + +interface ThreadFeedTurnFold { + readonly turnId: TurnId; + readonly createdAt: string; + readonly hiddenEntryIds: ReadonlySet; + readonly label: string; +} + +function deriveThreadFeedTurnFolds( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, +): ReadonlyMap { + const terminalAssistantMessageIdByTurn = new Map(); + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } + } + + interface TurnGroup { + readonly entries: ThreadFeedEntry[]; + readonly startBoundary: string | null; + } + const groupsByTurnId = new Map(); + let pendingUserBoundary: string | null = null; + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "user") { + pendingUserBoundary = entry.message.createdAt; + continue; + } + const turnId = + entry.type === "message" && entry.message.role === "assistant" + ? entry.message.turnId + : entry.type === "activity-group" + ? entry.turnId + : null; + if (!turnId) { + continue; + } + let group = groupsByTurnId.get(turnId); + if (!group) { + group = { + entries: [], + startBoundary: pendingUserBoundary, + }; + pendingUserBoundary = null; + groupsByTurnId.set(turnId, group); + } + group.entries.push(entry); + } + + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const foldsByAnchorId = new Map(); + for (const [turnId, group] of groupsByTurnId) { + const { entries } = group; + if (turnId === unsettledTurnId) { + continue; + } + if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + continue; + } + + const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + const hiddenEntryIds = new Set( + entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + ); + if (hiddenEntryIds.size === 0) { + continue; + } + + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); + if (!firstEntry || !lastEntry) { + continue; + } + const terminalEntry = terminalAssistantMessageId + ? entries.find((entry) => entry.id === terminalAssistantMessageId) + : null; + const latestTurnMatches = latestTurn?.turnId === turnId; + const lastEntryEnd = + lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + const elapsedMs = + latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt + ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) + : computeElapsedMs( + group.startBoundary ?? firstEntry.createdAt, + maxIsoTimestamp( + terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd, + ); + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; + const label = interrupted + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + + foldsByAnchorId.set(firstEntry.id, { + turnId, + createdAt: firstEntry.createdAt, + hiddenEntryIds, + label, + }); + } + return foldsByAnchorId; +} + +export function deriveThreadFeedPresentation( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, + expandedTurnIds: ReadonlySet, +): ThreadFeedEntry[] { + const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold"); + const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); + const collapsedEntryIds = new Set(); + for (const fold of foldsByAnchorId.values()) { + if (!expandedTurnIds.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } + } + } + + const result: ThreadFeedEntry[] = []; + for (const entry of sourceFeed) { + const fold = foldsByAnchorId.get(entry.id); + if (fold) { + result.push({ + type: "turn-fold", + id: `turn-fold:${fold.turnId}`, + createdAt: fold.createdAt, + turnId: fold.turnId, + label: fold.label, + expanded: expandedTurnIds.has(fold.turnId), + }); + } + if (!collapsedEntryIds.has(entry.id)) { + result.push(entry); + } + } + return result; +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -893,10 +1264,7 @@ export function buildThreadFeed( const loadedMessages = options?.loadedMessages ?? thread.messages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - const workLogEntries = deriveWorkLogEntries( - thread.activities, - thread.latestTurn?.turnId ?? undefined, - ); + const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ ...loadedMessages.map((message) => ({ @@ -921,18 +1289,33 @@ export function buildThreadFeed( oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt ); }) - .map((entry) => ({ - type: "activity", - id: entry.id, - createdAt: entry.createdAt, - activity: { + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const fullDetail = buildWorkEntryExpandedBody(entry); + return { + type: "activity", id: entry.id, createdAt: entry.createdAt, - summary: workEntryHeading(entry), - detail: workEntryPreview(entry), - status: null, - }, - })), + turnId: entry.turnId, + activity: { + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + summary, + detail, + fullDetail, + icon: workEntryIcon(entry), + copyText: [summary, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts new file mode 100644 index 000000000000..73f113eac389 --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.test.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000000..de7946f866dd --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.ts @@ -0,0 +1,22 @@ +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 000000000000..6a021dabcceb --- /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 000000000000..644fee365895 --- /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/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx new file mode 100644 index 000000000000..488766f36954 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -0,0 +1,21 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx new file mode 100644 index 000000000000..403f32a1de48 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -0,0 +1,16 @@ +import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return false; +} + +export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx new file mode 100644 index 000000000000..7dd92ff067fb --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -0,0 +1,187 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +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"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; +const EMPTY_SKILLS: NonNullable = []; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly selection: ComposerEditorSelection; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly value: string; + readonly tokensJson: string; + readonly selectionJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = EMPTY_SKILLS, + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; + return ( + } + onComposerChange={(event) => { + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + }} + onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx new file mode 100644 index 000000000000..dc2dfdfee037 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -0,0 +1,65 @@ +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"; + +export function ComposerEditor({ + ref, + skills: _skills, + selection, + onPasteImages, + style, + textStyle, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const inputRef = useRef(null); + const foregroundColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + setSelection: (nextSelection) => + inputRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + return ( + + props.onSelectionChange?.(event.nativeEvent.selection)} + multiline={props.multiline ?? true} + placeholderTextColor={placeholderColor} + style={[ + { + flex: 1, + minHeight: 0, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + ...MOBILE_TYPOGRAPHY.composer, + paddingVertical: contentInsetVertical, + }, + textStyle, + ]} + /> + + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts new file mode 100644 index 000000000000..d70d63fa4372 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -0,0 +1,38 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { Ref } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; + +export type ComposerEditorSelection = { + readonly start: number; + readonly end: number; +}; + +export interface ComposerEditorHandle { + focus: () => void; + blur: () => void; + setSelection: (selection: ComposerEditorSelection) => void; +} + +export interface ComposerEditorProps { + readonly ref?: Ref; + readonly value: string; + readonly skills?: ReadonlyArray< + Pick + >; + readonly selection?: ComposerEditorSelection; + readonly placeholder?: string; + readonly autoFocus?: boolean; + readonly editable?: boolean; + readonly scrollEnabled?: boolean; + readonly autoCorrect?: boolean; + readonly spellCheck?: boolean; + readonly multiline?: boolean; + readonly contentInsetVertical?: number; + readonly style?: StyleProp; + readonly textStyle?: StyleProp; + readonly onChangeText: (value: string) => void; + readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; + readonly onPasteImages?: (uris: ReadonlyArray) => void; + readonly onFocus?: () => void; + readonly onBlur?: () => void; +} diff --git a/apps/mobile/src/native/nativeViewResolutionError.ts b/apps/mobile/src/native/nativeViewResolutionError.ts new file mode 100644 index 000000000000..bfcf8351a66c --- /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 000000000000..b8b827585ea2 --- /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 000000000000..835dee7f7837 --- /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 000000000000..8199dee34866 --- /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 3eb94b32c067..000000000000 --- 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 000000000000..88d80631ad31 --- /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 000000000000..19d5b53c4e09 --- /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 000000000000..66bb3dc0bdea --- /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 000000000000..8c6e1738857a --- /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 000000000000..96171d3ea5c2 --- /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 000000000000..7a8799883281 --- /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 000000000000..68c23202308f --- /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 000000000000..ea6259959280 --- /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 000000000000..c29d01d397bc --- /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 000000000000..a52da3fc1348 --- /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 000000000000..f078572736b7 --- /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 054203715bd2..89abd3c222e2 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 000000000000..e4289d1f1d50 --- /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 000000000000..1b7060571a5b --- /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 000000000000..747ab7c72ee2 --- /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 000000000000..e879dd25e292 --- /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 000000000000..aa6255f85fff --- /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 000000000000..920267c33d5c --- /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 000000000000..7762e6cdf789 --- /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 000000000000..ed0c06ba38b9 --- /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 000000000000..2003c220badb --- /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 000000000000..68d06d2e424b --- /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 000000000000..d5eb383a0e97 --- /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 000000000000..7f2471230510 --- /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 000000000000..37ce280e9f41 --- /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 000000000000..22f971e09a5d --- /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 3111008f00af..000000000000 --- 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 000000000000..d02abb6a2656 --- /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 6ac9786ad0e3..9e2c1566190f 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 a42143a427b1..485b472dcb05 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 f4a65a0d283d..000000000000 --- 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 e5ab77a80af7..000000000000 --- 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 8a5ddac2c0fb..000000000000 --- 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 fc465bbfb88c..000000000000 --- 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 b7584858dc44..6fb41fc091f1 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 a28d33c65d1c..000000000000 --- 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 18860935f365..f320e9da710d 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 6c855a3ebf79..a8c037db6f77 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 232135b6a7eb..c9e9db125307 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 56d69db7bfbc..000000000000 --- 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 8f206be2ceeb..000000000000 --- 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 9ea13eef3e37..328557a2005d 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,84 +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, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, -} 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: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; -}) { - 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 7dfdc4cd57e4..0b8cba16e165 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,26 @@ 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"; +import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -76,112 +73,12 @@ 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(); @@ -197,18 +94,27 @@ export function useThreadComposerState() { const selectedThreadFeed = useMemo( () => - selectedThread - ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) + selectedThreadDetail + ? buildThreadFeed( + selectedThreadDetail, + selectedThreadQueuedMessages, + dispatchingQueuedMessageId, + ) : [], - [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], + [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], ); 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 +123,11 @@ 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; } @@ -230,95 +137,52 @@ export function useThreadComposerState() { selectedThreadSessionActivity, queuedSendStartedAt, ); - }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + }, [ + queuedSendStartedAt, + 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; } 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; } 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]); + try { + await enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId: MessageId.make(metadata.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); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + } + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -385,7 +249,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 +272,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 +318,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 900dbd648b50..388b4d9afcb9 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 000000000000..e912d6366b47 --- /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 000000000000..fb090cd0886b --- /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 c303faed6176..06175b6d2375 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 64e4da958eff..e169005a07fa 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 3af3a6e945ec..000000000000 --- 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 e7d7049d3322..000000000000 --- 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 000000000000..dc8c251149f6 --- /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 000000000000..368cd0bc4683 --- /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 000000000000..e51273d57de3 --- /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 000000000000..44c43d6c880f --- /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/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 5cbd6c442f59..56ada5f2a02f 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -58,9 +58,9 @@ export function AgentActivity( : "now"; const activeLabel = `${props.activeCount} active`; const isLight = environment.colorScheme === "light"; - const primaryForeground = isLight ? "#0f172a" : "#ffffff"; - const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; - const mutedForeground = isLight ? "#64748b" : "#94a3b8"; + const primaryForeground = isLight ? "#262626" : "#f5f5f5"; + const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; + const mutedForeground = isLight ? "#737373" : "#8e8e93"; const tint = environment.isLuminanceReduced ? secondaryForeground : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c330c74587d7..ebc4f984b86d 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"; @@ -47,7 +46,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"; @@ -72,19 +71,18 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; -import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.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"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; -import * as WebPushNotifier from "../src/push/WebPushNotifier.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 +179,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: ( @@ -297,7 +295,7 @@ 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; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -349,13 +347,13 @@ export const makeOrchestrationIntegrationHarness = ( }), ), Layer.provideMerge( - WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), + WorkspaceEntries.layer.pipe( + 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( @@ -374,21 +372,13 @@ export const makeOrchestrationIntegrationHarness = ( start: () => Effect.void, }), ), - Layer.provideMerge( - Layer.succeed(WebPushNotifier.WebPushNotifier, { - getStatus: () => Effect.succeed({ enabled: false }), - subscribe: () => Effect.succeed({ ok: true }), - unsubscribe: () => Effect.succeed({ ok: true }), - start: () => Effect.void, - }), - ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provideMerge(providerRegistryLayer), Layer.provide(persistenceLayer), - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), @@ -408,7 +398,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 e79897c740ed..ccfb9c467421 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 57e93c5acdd0..e703af4b1f45 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/package.json b/apps/server/package.json index 03de8c07d706..01003d7c1766 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,11 +27,11 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0", - "web-push": "^3.6.7" + "node-pty": "^1.1.0" }, "devDependencies": { "@effect/vitest": "catalog:", @@ -41,7 +41,6 @@ "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", "@types/node": "catalog:", - "@types/web-push": "^3.6.4", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", "vite-plus": "catalog:" diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef04..0d89775844de 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 aced9266733f..00b6c4cfcceb 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"; @@ -18,7 +17,16 @@ import { import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; 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; @@ -46,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))), ); @@ -63,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, }); } }); @@ -94,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); @@ -137,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); @@ -175,6 +173,7 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + shell: false, }), ); @@ -243,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 }); } } @@ -290,15 +287,15 @@ const publishCmd = Command.make( () => Effect.gen(function* () { const args = createVpPmPublishArgs(config); + const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); yield* runCommand( - ChildProcess.make("vp", ["pm", ...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ); }), diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts new file mode 100644 index 000000000000..91754290db9a --- /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 000000000000..d384c745f293 --- /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 04c2321870e1..b36c2b2d496f 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -1,8 +1,10 @@ // @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"; type JsonPrimitive = null | boolean | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -54,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 { @@ -122,17 +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) { - this.child = spawn(bin, args, { + const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); + this.child = NodeChildProcess.spawn(spawnCommand.command, spawnCommand.args, { cwd, - shell: process.platform === "win32", + shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: NodeProcess.env, }); this.child.on("exit", (code, signal) => { @@ -152,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`); }); } @@ -172,7 +175,7 @@ class JsonRpcChild { headers: [], ...message, }); - process.stdout.write(`>>> ${payload}\n`); + NodeProcess.stdout.write(`>>> ${payload}\n`); this.child.stdin.write(`${payload}\n`); } @@ -237,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; } @@ -432,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 new file mode 100644 index 000000000000..f790e71f5cdc --- /dev/null +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -0,0 +1,275 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import { 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 Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.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.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-asset-access-test-", +}); +const testLayer = Layer.mergeAll( + configLayer, + WorkspacePaths.layer, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), + ServerSecretStore.layer.pipe(Layer.provide(configLayer)), +).pipe(Layer.provideMerge(NodeServices.layer)); + +describe("AssetAccess", () => { + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-workspace-", + }); + const htmlPath = path.join(root, "report.html"); + const cssPath = path.join(root, "report.css"); + 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: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + 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, "report.html")).toEqual({ + kind: "file", + path: canonicalHtmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: canonicalCssPath, + }); + expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); + expect(yield* resolveAsset(token, ".env")).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects workspace files outside the authorized root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-outside-", + }); + const htmlPath = path.join(outside, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

outside

"); + + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + 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.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "ignored.png")).toEqual({ + kind: "file", + path: attachmentPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues project favicon capabilities with a signed fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-", + }); + 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 }, + }); + const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSeparatorIndex = faviconSuffix.indexOf("/"); + expect( + yield* resolveAsset( + faviconSuffix.slice(0, faviconSeparatorIndex), + faviconSuffix.slice(faviconSeparatorIndex + 1), + ), + ).toEqual({ kind: "file", path: canonicalFaviconPath }); + + yield* fileSystem.remove(faviconPath); + const fallbackResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); + expect( + yield* resolveAsset( + fallbackSuffix.slice(0, fallbackSeparatorIndex), + fallbackSuffix.slice(fallbackSeparatorIndex + 1), + ), + ).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 new file mode 100644 index 000000000000..8d8ecbc2af35 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.ts @@ -0,0 +1,433 @@ +import type { AssetResource } 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 { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { resolveAttachmentPathById } from "../attachmentStore.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 PREVIEW_ASSET_EXTENSIONS = new Set([ + ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, + ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, + ".css", + ".js", + ".mjs", + ".otf", + ".ttf", + ".woff", + ".woff2", +]); + +const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file"), + workspaceRoot: Schema.String, + 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"), + attachmentId: Schema.String, + expiresAt: Schema.Number, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon"), + workspaceRoot: Schema.String, + relativePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + }), +]); +type AssetClaims = typeof AssetClaimsSchema.Type; + +const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); +const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); +const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); + +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "project-favicon-fallback" }; + +function decodeClaims(encodedPayload: string): AssetClaims | null { + try { + return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +function decodeRelativePath(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + 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), + }), + ); + +const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( + function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { + const fileSystem = yield* FileSystem.FileSystem; + 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([ + optionOnNotFound(fileSystem.realPath(input.workspaceRoot)), + optionOnNotFound(fileSystem.realPath(resolved.value.absolutePath)), + ]); + if (Option.isNone(canonicalRoot) || Option.isNone(canonicalFile)) return null; + + const path = yield* Path.Path; + const relative = path.relative(canonicalRoot.value, canonicalFile.value); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return 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.WorkspacePaths; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let claims: AssetClaims; + let fileName: string; + + switch (input.resource._tag) { + case "workspace-file": { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + 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) => + 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* new AssetWorkspaceAssetNotFoundError({ + resource: input.resource, + }); + } + 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.ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: input.resource.attachmentId, + }); + if (!attachmentPath) { + return yield* new AssetAttachmentNotFoundError({ + resource: input.resource, + }); + } + claims = { + version: 1, + kind: "attachment", + attachmentId: input.resource.attachmentId, + expiresAt, + }; + fileName = path.basename(attachmentPath); + break; + } + case "project-favicon": { + 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 }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + )) + ) { + return yield* new AssetProjectFaviconNotFoundError({ + resource: input.resource, + }); + } + claims = { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ), + relativePath, + expiresAt, + }; + fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + break; + } + } + + 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 { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + expiresAt, + }; +}); + +export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( + token: string, + relativePath: string, +) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) return 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; + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + + if (claims.kind === "attachment") { + 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* 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; + } + + if (claims.kind === "project-favicon") { + if (claims.relativePath === null) { + return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; + } + const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + } + + 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 || + decodedPath.includes("\0") || + segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) || + !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase()) + ) { + return null; + } + const joinedRelativePath = + claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath); + const workspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ + workspaceRoot: claims.workspaceRoot, + relativePath: joinedRelativePath, + }); + return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; +}); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index 8c6999a73415..a5216f76b989 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,7 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import NodePath from "node:path"; - -export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; +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 7703902105ab..e21d9cf62cf5 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 1e8dd93f6039..3d5b531db217 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 871ec1eab60f..335e0685197b 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 d8c0079089fc..dd53a83ca957 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 44c28dea416a..03009270e15c 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 e15496c39306..a8fb88e6f0cf 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 4fe6ec3b17b4..48f5f0232b2c 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; // A loopback bind is normally local-only, but with Tailscale Serve enabled the // backend is reachable from other tailnet devices over HTTPS (Serve proxies the // *.ts.net endpoint to 127.0.0.1), so it must be treated as remotely reachable @@ -51,10 +49,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 3861b4fc78f2..53b1a7e79296 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 e97696fbadd3..7a8fb9477cf3 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 93339f4d4dba..d4411fb9f3b7 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 3b84ba58377d..5e9890c1ea28 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 00abd6b99456..334c24ef52fd 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 8de145ca338b..12ecb7dba4d8 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 76898bc9463d..fa75c407b0c6 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 66cd07f9e2e3..f19984eb3690 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 ed640863d21f..71fb00b970a0 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 7260ac7c54db..39f04988ac55 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 27a1d55e90d5..5c713ff2be78 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"; @@ -20,17 +20,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"; @@ -57,7 +57,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", @@ -84,26 +84,23 @@ const makeCliTestServerConfig = (baseDir: string) => logWebSocketEvents: false, 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))); }); @@ -133,7 +130,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( @@ -200,7 +197,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"]), ); @@ -223,7 +222,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]), ); @@ -237,11 +238,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", @@ -270,7 +273,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]), ); @@ -281,24 +286,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"]), @@ -328,7 +337,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"]), @@ -403,8 +414,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", @@ -447,8 +462,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* () { @@ -461,7 +480,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, @@ -475,8 +494,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 422d880d7f1d..05155f32ec4c 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,7 +1,7 @@ // @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 { assert, it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; @@ -11,11 +11,21 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; - -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +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(); @@ -28,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); + }, }; }); @@ -41,14 +59,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { - it.effect("uses platform-specific fd paths", () => - Effect.sync(() => { - assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); - assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); - assert.equal(resolveFdPath(3, "win32"), undefined); - }), - ); - it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -60,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 }); @@ -85,11 +95,13 @@ 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 { - const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); assertSome(payload, { mode: "desktop", }); @@ -99,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"], }), ), @@ -130,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 9ad6328798d0..0f2a5a436a37 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,21 +1,75 @@ // @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"; import * as Result from "effect/Result"; 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, @@ -31,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, }); @@ -52,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, }), ), @@ -67,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, }), ), @@ -95,62 +152,67 @@ 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.try({ - try: () => { - const fdPath = resolveFdPath(fd); - if (fdPath === undefined) { - return makeDirectBootstrapStream(fd); - } + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ + try: () => { + if (fdPath === undefined) { + return makeDirectBootstrapStream(fd); + } - let streamFd: number | undefined; - try { - streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); - } catch (error) { - if (isBootstrapFdPathDuplicationError(error)) { - if (streamFd !== undefined) { - NFS.closeSync(streamFd); + let streamFd: number | undefined; + try { + streamFd = NodeFS.openSync(fdPath, "r"); + return NodeFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NodeFS.closeSync(streamFd); + } + return makeDirectBootstrapStream(fd); } - return makeDirectBootstrapStream(fd); + throw error; } - throw error; - } - }, - catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", - cause: error, - }), + }, + catch: (error) => + 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, @@ -165,10 +227,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", ); -export function resolveFdPath( - fd: number, - platform: NodeJS.Platform = process.platform, -): string | undefined { +function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { if (platform === "linux") { return `/proc/self/fd/${fd}`; } diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts new file mode 100644 index 000000000000..c1dbc8337183 --- /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 b07c06ac9362..077506ff3a84 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 778956e5206b..bf332d20d0da 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 000000000000..f13aa4572c17 --- /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 000000000000..4c8b9c59cc31 --- /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 6feb58d584a6..bdf409e29716 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 9f31532855a9..000000000000 --- 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 53b8d163e4c9..000000000000 --- 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 4bb8b111827b..000000000000 --- 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 a7c4c3dbef04..000000000000 --- 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 4f1fc48871dc..1b349111811c 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.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5e..d4d9d3785571 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,4 @@ -import NodeOS from "node:os"; +import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7182854e18c4..7a9cd72d526d 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -14,18 +14,10 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; 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, ); @@ -104,7 +96,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), ), @@ -139,7 +131,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 baseDir: Option.Option; @@ -208,7 +200,7 @@ export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, options?: { - readonly startupPresentation?: StartupPresentation; + readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; }, ) => @@ -238,7 +230,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), @@ -257,9 +249,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); }, }, ); @@ -279,8 +271,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, ); @@ -330,7 +322,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, @@ -341,7 +333,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 5fce3bc1cd7d..70b0329ac90a 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 167fb75a37cc..3ce53391fa64 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,6 +6,8 @@ import { } from "@t3tools/contracts"; 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"; @@ -29,9 +31,9 @@ import * as CliState from "../cloud/CliState.ts"; 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 { ServerConfig } from "../config.ts"; -import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.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"; @@ -143,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, ) { @@ -162,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( @@ -178,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; @@ -210,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; @@ -217,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; @@ -228,12 +245,49 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f httpClient.execute, Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + withRelayClientTracing, ); return response.ok ? ({ status: "revoked" } satisfies RelayUnlinkResult) : ({ 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; }) { @@ -247,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."); @@ -282,8 +324,8 @@ const runCloudCommand = ( | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment - | ServerConfig - | ServerEnvironment + | ServerConfig.ServerConfig + | ServerEnvironment.ServerEnvironment >, options?: { readonly quietLogs?: boolean; @@ -298,10 +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)); @@ -381,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 000000000000..5395592c889a --- /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 0d8e7eca15d1..710d39c4c290 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 2798f5b6eded..3fbf4f12db20 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 f344a0b73cc2..2e18fff42500 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 765ef0583322..00709370b269 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 9ce33deaaefb..e0d5924fcc2c 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 73e549ebf49b..a1d7112a9299 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 3a033d503037..48c44ccc48ad 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 beef4729992c..1d0cde91bf4f 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 799ab609f433..ed2e5a4cf759 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -3,21 +3,21 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import { HttpClient } from "effect/unstable/http"; +import * as Tracer from "effect/Tracer"; +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 } from "./http.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.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", @@ -29,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, @@ -40,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* () { @@ -69,6 +93,70 @@ describe("consumeCloudReplayGuards", () => { ); }); +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({ + 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* traceAuthenticatedRelayRequest( + 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).toBe("0123456789abcdef0123456789abcdef"); + expect(Option.getOrUndefined(span.parent)?.spanId).toBe("0123456789abcdef"); + }), + ); +}); + describe("reconcileDesiredCloudLink", () => { it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => Effect.gen(function* () { @@ -84,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 896990849b6c..fc2adca9fbc6 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -29,6 +29,7 @@ import { RelayLinkProofRequest, RelayManagedEndpointOrigin, } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeRelayIssuer, RELAY_HEALTH_REQUEST_TYP, @@ -54,14 +55,8 @@ 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, @@ -73,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-"; @@ -101,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( () => @@ -119,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; }) { @@ -127,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), @@ -206,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( @@ -235,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({})), ), ); } @@ -333,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, @@ -395,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, }), ), @@ -417,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* ( @@ -478,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 = ( @@ -512,6 +506,7 @@ const relayClientRequest = ( message: `T3 Connect relay request failed: ${String(cause)}`, }), ), + withRelayClientTracing, ); const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( @@ -581,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, @@ -591,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, }), ); @@ -619,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; }); @@ -634,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."), ), ); @@ -656,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."), ), ); @@ -677,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; @@ -774,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, }), ), @@ -791,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; @@ -896,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, }), ), @@ -911,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( @@ -938,7 +935,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - 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 558560bfffb2..c46e2671a46e 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -1,8 +1,13 @@ 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, makeRelayUrlConfig } from "./publicConfig.ts"; +import { + makeCloudCliOAuthConfig, + makeRelayUrlConfig, + resolveRelayClientTracingConfig, +} from "./publicConfig.ts"; const provideEnv = (env: Readonly>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); @@ -83,3 +88,60 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va clerkCliOAuthClientIdFallback: "", }).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", + tracesDataset: "embedded-dataset", + tracesToken: "embedded-token", + }; + + assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback); + assert.deepEqual( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + { + tracesUrl: "https://runtime.example.test/v1/traces", + tracesDataset: "runtime-dataset", + tracesToken: "runtime-token", + }, + ); + assert.equal( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + null, + ); +}); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 5c64a2423774..176b31d7566a 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"; @@ -9,6 +10,9 @@ import * as SchemaIssue from "effect/SchemaIssue"; declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; @@ -32,6 +36,15 @@ function readBuildTimeValue(value: string | undefined): string { return typeof value === "undefined" ? "" : value.trim(); } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" @@ -46,6 +59,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); +export const buildTimeRelayClientTracing = { + tracesUrl: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__, + ), + tracesDataset: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__, + ), + tracesToken: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__, + ), +} as const; + +export function resolveRelayClientTracingConfig( + env: Readonly> = process.env, + fallback = buildTimeRelayClientTracing, +) { + const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl; + const tracesDataset = + env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset; + const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken; + const normalizedTracesUrl = normalizeSecureUrl(tracesUrl); + return normalizedTracesUrl && tracesDataset && tracesToken + ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken } + : null; +} export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL"); @@ -88,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/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts new file mode 100644 index 000000000000..e35c94545a5e --- /dev/null +++ b/apps/server/src/cloud/relayTracing.ts @@ -0,0 +1,21 @@ +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; + +import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; + +const relayClientTracingConfig = resolveRelayClientTracingConfig(); + +export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( + relayClientTracingConfig, + { + serviceName: "t3-headless-relay-client", + runtime: "node", + client: "headless-cli", + }, +); + +export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { + serviceName: "t3-server", + runtime: "node", + client: "environment-server", + component: "relay-broker", +}); diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts new file mode 100644 index 000000000000..1481b891224d --- /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 b0a23cb273c9..2608ccc16aee 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"; export const DEFAULT_PORT = 3773; @@ -46,38 +46,51 @@ 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 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 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"); @@ -129,56 +142,50 @@ 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 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); - 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, + desktopBootstrapToken: undefined, + staticDir: undefined, + devUrl, + noBrowser: false, + startupPresentation: "browser", + }); +}); - 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, - desktopBootstrapToken: undefined, - staticDir: undefined, - devUrl, - noBrowser: false, - startupPresentation: "browser", - } satisfies ServerConfigShape; - }), - ); -} +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 18a54326de17..7d16a11c829c 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 ed81f021f4bb..b39d560a2280 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -4,6 +4,7 @@ import type { ServerProcessSignal, ServerSignalProcessResult, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -11,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"; @@ -30,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}'.`; + } +} + +class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsServerProcessSignalError", + { pid: Schema.Number }, +) { + override get message(): string { + return "Refusing to signal the T3 server process."; + } +} -function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { - return new ProcessDiagnosticsError({ - message, - ...(cause === undefined ? {} : { cause }), - }); +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; @@ -265,21 +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( @@ -300,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, @@ -331,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)), ), ); @@ -357,20 +453,30 @@ 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)), ), ); } -export const readProcessRows = (platform = process.platform) => - platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +export const readProcessRows = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +}); export function aggregateProcessDiagnostics(input: { readonly serverPid: number; @@ -384,10 +490,14 @@ 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( + return readProcessRows.pipe( Effect.flatMap((rows) => { const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); const descendant = buildDescendantEntries(filteredRows, process.pid).some( @@ -396,18 +506,21 @@ 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( + const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return makeResult({ serverPid: process.pid, rows, readAt }); @@ -421,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), @@ -437,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) => @@ -458,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 11d12c012db1..d9c4eb06ef18 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 2b6dfe8d3625..6030e4172e1d 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 d4ffa4a5fc29..70bb4dc815c3 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 ff63410b9bca..d54e033380c0 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 6904c53c8471..000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ /dev/null @@ -1,132 +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 { 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, - 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 cc8d803c9705..000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -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(): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (process.platform) { - case "darwin": - return "darwin"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (process.arch) { - 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 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(), - arch: platformArch(), - }, - 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 827f562422ee..000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ /dev/null @@ -1,178 +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 { 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(""), - }), -); - -afterEach(() => { - runMock.mockReset(); -}); - -describe("resolveServerEnvironmentLabel", () => { - it.effect("uses hostname fallback regardless of launch mode", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - platform: "win32", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); - - 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", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); - - 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", - platform: "linux", - hostname: "buildbox", - }).pipe(Effect.provide(LinuxMachineInfoLayer)); - - 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", - platform: "linux", - hostname: "runner-01", - }).pipe(Effect.provide(TestLayer)); - - 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", - platform: "win32", - hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(TestLayer)); - - 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", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); - - 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", - platform: "linux", - hostname: " ", - }).pipe(Effect.provide(TestLayer)); - - 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 b07425b936b5..000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as OS from "node:os"; - -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; - readonly platform?: NodeJS.Platform; - readonly hostname?: string | null; -} - -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* ( - platform: NodeJS.Platform, -) { - 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 platform = input.platform ?? process.platform; - const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); - if (friendlyHostLabel) { - return friendlyHostLabel; - } - - const hostname = normalizeLabel(input.hostname ?? OS.hostname()); - 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 000000000000..6b3290246fea --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -0,0 +1,132 @@ +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 * 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, + 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 000000000000..b5fbd8e1088c --- /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 000000000000..b5bb8a8ff1c4 --- /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 000000000000..bd034e0fa269 --- /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 1e6dea0d05f1..000000000000 --- 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 bee861677a5b..e1924c03adea 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 92a663c2263e..c375455df892 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; @@ -1261,16 +1308,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, { @@ -1299,7 +1348,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, @@ -1311,14 +1361,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", @@ -1354,51 +1411,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 [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(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) => { @@ -1413,9 +1477,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), ), ); }; @@ -1513,10 +1579,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( @@ -1541,10 +1609,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({ @@ -1579,10 +1649,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); @@ -1599,7 +1670,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()); @@ -1619,16 +1690,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[] = [ @@ -1644,13 +1717,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 }; @@ -1659,8 +1737,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, + }), ), ); @@ -1784,7 +1868,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - return { + return GitManager.of({ localStatus, remoteStatus, status, @@ -1794,7 +1878,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 9a34680496f1..2ea14b951fe2 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 74064450fcb8..100b9beadbad 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 b414daaa0a46..e4a703f44540 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 517d57168c39..ce9b498cb1f1 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,33 +24,30 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; +import * as ServerConfig from "./config.ts"; import { - ATTACHMENTS_ROUTE_PREFIX, - normalizeAttachmentRelativePath, - resolveAttachmentRelativePath, -} from "./attachmentPaths.ts"; -import { resolveAttachmentPathById } from "./attachmentStore.ts"; -import { resolveStaticDir, ServerConfig } from "./config.ts"; -import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; + ASSET_ROUTE_PREFIX, + FALLBACK_PROJECT_FAVICON_SVG, + resolveAsset, +} from "./assets/AssetAccess.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 PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; -const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); 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 } : {}), @@ -84,10 +81,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); @@ -98,13 +97,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), ); }), ); @@ -120,9 +119,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); @@ -169,107 +168,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const attachmentsRouteLayer = HttpRouter.add( +export const assetRouteLayer = HttpRouter.add( "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, + `${ASSET_ROUTE_PREFIX}/*`, Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } - const config = yield* ServerConfig; - const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); - if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { status: 400 }); - } - - const isIdLookup = - !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); - const filePath = isIdLookup - ? resolveAttachmentPathById({ - attachmentsDir: config.attachmentsDir, - attachmentId: normalizedRelativePath, - }) - : resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: normalizedRelativePath, - }); - if (!filePath) { - return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", { - status: isIdLookup ? 404 : 400, - }); - } - - const fileSystem = yield* FileSystem.FileSystem; - const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!fileInfo || fileInfo.type !== "File") { + const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(filePath, { - status: 200, - headers: { - "Cache-Control": "public, max-age=31536000, immutable", - }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), -); - -export const projectFaviconRouteLayer = HttpRouter.add( - "GET", - "/api/project-favicon", - Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } - - const projectCwd = url.value.searchParams.get("cwd"); - if (!projectCwd) { - return HttpServerResponse.text("Missing cwd parameter", { status: 400 }); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); } - - const faviconResolver = yield* ProjectFaviconResolver; - const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd); - if (!faviconFilePath) { + if (asset.kind === "project-favicon-fallback") { return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { status: 200, contentType: "image/svg+xml", headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }); } - return yield* HttpServerResponse.file(faviconFilePath, { + return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { - "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", }, }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), + }), ); export const staticAndDevRouteLayer = HttpRouter.add( @@ -283,14 +225,15 @@ export const staticAndDevRouteLayer = HttpRouter.add( return HttpServerResponse.text("Bad Request", { status: 400 }); } - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, }); } - const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + const staticDir = + config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined); if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 188a8d32d18c..a51ad20afbe8 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,9 @@ 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"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), @@ -213,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, [ { @@ -238,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 @@ -250,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; }); @@ -277,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; }); @@ -298,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")); @@ -312,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; }); @@ -343,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", @@ -369,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", @@ -392,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", @@ -417,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", @@ -439,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", @@ -459,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", @@ -475,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", @@ -488,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" }, @@ -496,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", @@ -514,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; @@ -533,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", @@ -555,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( @@ -563,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 80b522eee719..5ddae4943f8c 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 new file mode 100644 index 000000000000..f550396c6602 --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -0,0 +1,283 @@ +import { expect, it } from "@effect/vitest"; +import { NodeHttpServer } from "@effect/platform-node"; +import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { McpSchema, McpServer } from "effect/unstable/ai"; +import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; + +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; + +const environmentId = EnvironmentId.make("environment-mcp-test"); +const threadId = ThreadId.make("thread-mcp-test"); +const tabId = PreviewTabId.make("tab-mcp-test"); +const invocation = { + environmentId, + threadId, + providerSessionId: "provider-session-mcp-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, +}; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); +const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(PreviewAutomationBroker.layer), +); + +it("normalizes empty successful notification responses to accepted", () => { + const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( + HttpServerResponse.text("", { status: 200, contentType: "application/json" }), + ); + expect(notificationResponse.status).toBe(202); + + const resultResponse = McpHttpServer.normalizeMcpHttpResponse( + HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), + ); + 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* () { + const serverLayer = McpServer.layerHttp({ + name: "MCP termination test", + version: "1.0.0", + path: "/mcp", + }); + yield* HttpRouter.serve(serverLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const httpClient = yield* HttpClient.HttpClient; + + const initializeResponse = yield* httpClient.post("/mcp", { + headers: { accept: "application/json, text/event-stream" }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, + "application/json", + ), + }); + const sessionId = initializeResponse.headers["mcp-session-id"]; + expect(initializeResponse.status).toBe(200); + expect(sessionId).not.toBeNull(); + + const missingSessionResponse = yield* httpClient.del("/mcp"); + expect(missingSessionResponse.status).toBe(400); + + const unknownSessionResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": "unknown-session" }, + }); + expect(unknownSessionResponse.status).toBe(404); + + const terminateResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": sessionId! }, + }); + expect(terminateResponse.status).toBe(204); + + const reusedSessionResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId!, + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, + "application/json", + ), + }); + expect(reusedSessionResponse.status).toBe(404); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("registers annotated tools and preserves authenticated request context", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + 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, + ok: true, + result: + request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "mcp-test-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); + expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); + expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); + + const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); + expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); + expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); + + const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); + expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); + expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); + expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); + + const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); + expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); + expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); + + const status = yield* server + .callTool({ name: "preview_status", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(status.isError).toBe(false); + expect(status.structuredContent).toMatchObject({ + available: true, + tabId, + }); + + const malformed = yield* server + .callTool({ name: "preview_click", arguments: { selector: "" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(malformed.isError).toBe(true); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(snapshot.isError).toBe(false); + expect(snapshot.content.some((content) => content.type === "image")).toBe(true); + expect(snapshot.structuredContent).toMatchObject({ + screenshot: { mimeType: "image/png", width: 10, height: 5 }, + }); + + const press = yield* server + .callTool({ name: "preview_press", arguments: { key: "Enter" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(press.isError).toBe(false); + expect(press.structuredContent).toBeNull(); + expect(press.content).toEqual([{ type: "text", text: "null" }]); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts new file mode 100644 index 000000000000..e95662a30f89 --- /dev/null +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -0,0 +1,220 @@ +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 Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import type * as Types from "effect/Types"; +import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import { + PreviewSnapshotToolkitHandlersLive, + PreviewStandardToolkitHandlersLive, +} from "./toolkits/preview/handlers.ts"; +import { + PreviewSnapshotTool, + PreviewSnapshotToolkit, + PreviewStandardToolkit, +} from "./toolkits/preview/tools.ts"; + +const unauthorized = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mcp_credential", + message: "A valid provider-scoped MCP bearer credential is required.", + }, + { + status: 401, + headers: { + "cache-control": "no-store", + "www-authenticate": "Bearer", + }, + }, +); + +type AuthenticatedHttpEffect = Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + McpInvocationContext.McpInvocationContext +>; + +type McpAuthMiddleware = ( + httpEffect: AuthenticatedHttpEffect, +) => Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + HttpServerRequest.HttpServerRequest +>; + +export const normalizeMcpHttpResponse = ( + response: HttpServerResponse.HttpServerResponse, +): HttpServerResponse.HttpServerResponse => { + const bodyIsEmpty = + response.body._tag === "Empty" || + (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || + (response.body._tag === "Raw" && response.body.contentLength === 0); + return response.status === 200 && bodyIsEmpty + ? HttpServerResponse.setStatus(response, 202) + : response; +}; + +const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( + Effect.map( + (registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), + ), + Effect.withSpan("McpHttpServer.makeAuthMiddleware"), +); + +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; + const built = yield* PreviewSnapshotToolkit; + const tool = PreviewSnapshotTool; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + Effect.withFiber((fiber) => { + const invocation = Context.getUnsafe( + fiber.context, + McpInvocationContext.McpInvocationContext, + ); + return built.handle("preview_snapshot", payload).pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.matchCauseEffect({ + onFailure: previewSnapshotFailure, + onSuccess: ({ encodedResult }) => { + const snapshot = encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + 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, + }, + ], + }), + ); + }, + }), + ); + }), + }); +}); + +const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( + Layer.provide(PreviewStandardToolkitHandlersLive), +); + +const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( + Layer.provide(PreviewSnapshotToolkitHandlersLive), +); + +export const PreviewToolkitRegistrationLive = Layer.mergeAll( + PreviewStandardToolkitRegistrationLive, + PreviewSnapshotRegistrationLive, +); + +const McpTransportLive = McpServer.layerHttp({ + name: "T3 Code", + version: packageJson.version, + path: "/mcp", +}).pipe(Layer.provide(McpAuthMiddlewareLive)); + +export const layer = PreviewToolkitRegistrationLive.pipe( + Layer.provideMerge(McpTransportLive), + Layer.provide(PreviewAutomationBroker.layer), +); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts new file mode 100644 index 000000000000..39c686890473 --- /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 new file mode 100644 index 000000000000..b13bf2d312e8 --- /dev/null +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -0,0 +1,41 @@ +import { + type EnvironmentId, + PreviewAutomationUnavailableError, + type ProviderInstanceId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export type McpCapability = "preview"; + +export interface McpInvocationScope { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet; + readonly issuedAt: number; + readonly expiresAt: number; +} + +export class McpInvocationContext extends Context.Service< + McpInvocationContext, + McpInvocationScope +>()("t3/mcp/McpInvocationContext") {} + +export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( + capability: McpCapability, +) { + const invocation = yield* McpInvocationContext; + if (!invocation.capabilities.has(capability)) { + return yield* new PreviewAutomationUnavailableError({ + capability, + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }); + } + return invocation; +}); diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts new file mode 100644 index 000000000000..d5dc582046c1 --- /dev/null +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +export interface McpProviderSessionConfig { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly providerSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authorizationHeader: string; +} + +const sessionsByThread = new Map(); + +export function setMcpProviderSession(config: McpProviderSessionConfig): void { + sessionsByThread.set(config.threadId, config); +} + +export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { + return sessionsByThread.get(threadId); +} + +export function clearMcpProviderSession(threadId: ThreadId): void { + sessionsByThread.delete(threadId); +} + +export function clearAllMcpProviderSessions(): void { + sessionsByThread.clear(); +} diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts new file mode 100644 index 000000000000..a91d98febd80 --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -0,0 +1,90 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpServer } from "effect/unstable/http"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +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, httpServer = fakeHttpServer) => + McpSessionRegistry.__testing + .make({ + now, + idleTimeoutMs: 100, + maximumLifetimeMs: 1_000, + }) + .pipe( + Effect.provideService(HttpServer.HttpServer, httpServer), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); + +it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const threadId = ThreadId.make("thread-1"); + const issued = yield* registry.issue({ + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + expect(token.length).toBeGreaterThan(20); + + const resolved = yield* registry.resolve(token); + expect(resolved?.threadId).toBe(threadId); + + yield* registry.revokeThread(threadId); + expect(yield* registry.resolve(token)).toBeUndefined(); + + timestamp += 2_000; + }), +); + +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; + const registry = yield* makeRegistry(() => timestamp); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-2"), + providerInstanceId: ProviderInstanceId.make("claude"), + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + timestamp += 101; + expect(yield* registry.resolve(token)).toBeUndefined(); + }), +); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts new file mode 100644 index 000000000000..67c4f2f0ff09 --- /dev/null +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -0,0 +1,214 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpServer } from "effect/unstable/http"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpProviderSession from "./McpProviderSession.ts"; + +export interface McpCredentialRequest { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +} + +export interface McpIssuedCredential { + readonly config: McpProviderSession.McpProviderSessionConfig; + readonly expiresAt: number; +} + +export interface McpSessionRegistryShape { + readonly issue: (request: McpCredentialRequest) => Effect.Effect; + readonly resolve: ( + rawToken: string, + ) => Effect.Effect; + readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; + readonly revokeThread: (threadId: ThreadId) => Effect.Effect; + readonly revokeAll: Effect.Effect; +} + +export class McpSessionRegistry extends Context.Service< + McpSessionRegistry, + McpSessionRegistryShape +>()("t3/mcp/McpSessionRegistry") {} + +interface CredentialRecord { + readonly tokenHash: string; + readonly scope: McpInvocationContext.McpInvocationScope; + readonly lastUsedAt: number; +} + +interface RegistryState { + readonly records: ReadonlyMap; +} + +export interface McpSessionRegistryOptions { + readonly idleTimeoutMs?: number; + readonly maximumLifetimeMs?: number; + readonly now?: () => number; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; +const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +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.ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const httpServer = yield* HttpServer.HttpServer; + const state = yield* SynchronizedRef.make({ records: new Map() }); + const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; + const endpoint = + httpServer.address._tag === "TcpAddress" + ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` + : "http://127.0.0.1/mcp"; + + const hashToken = (token: string) => + crypto + .digest("SHA-256", new TextEncoder().encode(token)) + .pipe(Effect.map(bytesToHex), Effect.orDie); + + const pruneExpired = (records: ReadonlyMap, timestamp: number) => { + const next = new Map( + Array.from(records).filter( + ([, record]) => + timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, + ), + ); + return next.size === records.size ? records : next; + }; + + const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( + function* (request) { + const issuedAt = yield* currentTimeMillis; + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const expiresAt = issuedAt + maximumLifetimeMs; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set(["preview"]), + issuedAt, + expiresAt, + }; + yield* SynchronizedRef.update(state, ({ records }) => { + const next = new Map(pruneExpired(records, issuedAt)); + next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); + return { records: next }; + }); + return { + config: { + environmentId, + threadId: scope.threadId, + providerSessionId, + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + }, + expiresAt, + }; + }, + ); + + const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( + function* (rawToken) { + if (rawToken.length === 0) return undefined; + const tokenHash = yield* hashToken(rawToken); + const timestamp = yield* currentTimeMillis; + return yield* SynchronizedRef.modify(state, ({ records }) => { + const current = pruneExpired(records, timestamp); + const record = current.get(tokenHash); + if (!record) return [undefined, { records: current }] as const; + const next = new Map(current); + next.set(tokenHash, { ...record, lastUsedAt: timestamp }); + return [record.scope, { records: next }] as const; + }); + }, + ); + + const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => + SynchronizedRef.update(state, ({ records }) => ({ + records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), + })); + + return McpSessionRegistry.of({ + issue, + resolve, + revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( + function* (providerSessionId) { + yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + }, + ), + revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { + yield* revokeWhere((record) => record.scope.threadId === threadId); + }), + revokeAll: SynchronizedRef.set(state, { records: new Map() }), + }); +}); + +let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; + +const make = Effect.acquireRelease( + makeWithOptions().pipe( + Effect.tap((registry) => + Effect.sync(() => { + activeMcpSessionRegistry = registry; + }), + ), + ), + (registry) => + Effect.sync(() => { + if (activeMcpSessionRegistry === registry) { + activeMcpSessionRegistry = undefined; + } + }), +); + +export const layer = Layer.effect(McpSessionRegistry, make); + +export const issueActiveMcpCredential = ( + request: McpCredentialRequest, +): Effect.Effect => + activeMcpSessionRegistry + ? activeMcpSessionRegistry + .revokeThread(request.threadId) + .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + : Effect.sync((): McpIssuedCredential | undefined => undefined); + +export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; + +export const revokeAllActiveMcpCredentials = (): Effect.Effect => + activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; + +/** Exposed for tests. */ +export const __testing = { + make: makeWithOptions, +}; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts new file mode 100644 index 000000000000..9f7ef2113d7b --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -0,0 +1,299 @@ +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"; + +const scope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + issuedAt: 1, + expiresAt: 2, +}; + +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.make; + const requests = yield* broker.connect(makeOwner()); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: true, + result: { available: true }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const result = yield* broker.invoke<{ available: boolean }>({ + scope, + operation: "open", + input: {}, + }); + + expect(result).toEqual({ available: true }); + }), + ), +); + +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.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.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.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, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 15_000, + }); + }), + ), +); + +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 new file mode 100644 index 000000000000..a2bdb95f0611 --- /dev/null +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -0,0 +1,410 @@ +import { + PreviewAutomationClientDisconnectedError, + PreviewAutomationControlInterruptedError, + PreviewAutomationExecutionError, + PreviewAutomationHostNotConnectedError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationRequestQueueClosedError, + PreviewAutomationResultTooLargeError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationUnsupportedClientError, + type PreviewAutomationError, + type PreviewAutomationOperation, + type PreviewAutomationOwner, + type PreviewAutomationOwnerIdentity, + type PreviewAutomationRequest, + type PreviewAutomationResponse, + type PreviewTabId, +} 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 Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +export interface PreviewAutomationInvokeInput { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly operation: PreviewAutomationOperation; + readonly input: unknown; + readonly tabId?: PreviewTabId; + readonly timeoutMs?: number; +} + +export class PreviewAutomationBroker extends Context.Service< + PreviewAutomationBroker, + { + 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; +} + +interface PendingRequest { + 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 { + readonly clients: ReadonlyMap; + readonly owners: ReadonlyMap; + readonly pending: ReadonlyMap; + readonly requestSequence: number; +} + +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({ + ...context, + ...remoteDiagnostics, + }); + case "PreviewAutomationUnsupportedClientError": + return new PreviewAutomationUnsupportedClientError({ + ...context, + ...remoteDiagnostics, + }); + case "PreviewAutomationTabNotFoundError": + return new PreviewAutomationTabNotFoundError({ + ...context, + ...remoteDiagnostics, + }); + case "PreviewAutomationTimeoutError": + return new PreviewAutomationTimeoutError({ + ...context, + ...remoteDiagnostics, + }); + case "PreviewAutomationControlInterruptedError": + return new PreviewAutomationControlInterruptedError({ + ...context, + ...remoteDiagnostics, + }); + case "PreviewAutomationInvalidSelectorError": { + return new PreviewAutomationInvalidSelectorError({ + ...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({ + ...context, + ...remoteDiagnostics, + ...(maximumBytes === undefined ? {} : { maximumBytes }), + }); + } + case "PreviewAutomationUnavailableError": + return new PreviewAutomationRemoteUnavailableError({ + ...context, + ...remoteDiagnostics, + }); + default: + return new PreviewAutomationExecutionError({ + ...context, + ...remoteDiagnostics, + }); + } +}; + +export const make = Effect.gen(function* PreviewAutomationBrokerMake() { + const state = yield* SynchronizedRef.make({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + requestSequence: 0, + }); + + const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( + clientId: string, + queue: ClientConnection["queue"], + ) { + const toFail = yield* SynchronizedRef.modify(state, (current) => { + const clients = new Map(current.clients); + const owners = new Map(current.owners); + const pending = new Map(current.pending); + const disconnected: PendingRequest[] = []; + if (current.clients.get(clientId)?.queue === queue) { + clients.delete(clientId); + owners.delete(clientId); + } + for (const [requestId, entry] of pending) { + if (entry.queue === queue) { + pending.delete(requestId); + disconnected.push(entry); + } + } + return [disconnected, { ...current, clients, owners, pending }] as const; + }); + yield* Effect.forEach( + toFail, + ({ deferred, context }) => + Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), + { discard: true }, + ); + yield* Queue.shutdown(queue); + }); + + const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( + "PreviewAutomationBroker.connect", + )(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 }); + 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: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( + "PreviewAutomationBroker.reportOwner", + )(function* (owner) { + yield* SynchronizedRef.update(state, (current) => { + const owners = new Map(current.owners); + owners.set(owner.clientId, owner); + return { ...current, owners }; + }); + }); + + const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( + "PreviewAutomationBroker.clearOwner", + )(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(owner.clientId); + return { ...current, owners }; + }); + }); + + const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( + "PreviewAutomationBroker.respond", + )(function* (response) { + const pending = yield* SynchronizedRef.modify(state, (current) => { + const entry = current.pending.get(response.requestId); + if (!entry) return [undefined, current] as const; + const next = new Map(current.pending); + next.delete(response.requestId); + return [entry, { ...current, pending: next }] as const; + }); + if (!pending) return; + if (response.ok) { + yield* Deferred.succeed(pending.deferred, response.result); + } else { + yield* Deferred.fail( + pending.deferred, + response.error + ? classifyResponseError(pending.context, response.error) + : new PreviewAutomationMalformedResponseError(pending.context), + ); + } + }); + + const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( + input: Parameters[0], + ): Effect.fn.Return { + const current = yield* SynchronizedRef.get(state); + const candidates = Array.from(current.owners.values()) + .filter( + (owner) => + owner.environmentId === input.scope.environmentId && + owner.threadId === input.scope.threadId && + owner.supportsAutomation, + ) + .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); + 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({ + 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 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, 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, { 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; + const pending = new Map(next.pending); + pending.delete(requestId); + return { ...next, pending }; + }); + const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { + const offered = yield* Queue.offer(connection.queue, { + requestId, + threadId: input.scope.threadId, + tabId: requestContext.tabId, + operation: input.operation, + input: input.input, + timeoutMs, + }); + if (!offered) { + 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(requestContext)), + onSome: (value) => Effect.succeed(value as A), + }); + }); + return yield* awaitResponse().pipe(Effect.ensuring(removePending)); + }); + + return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); +}).pipe(Effect.withSpan("PreviewAutomationBroker.make")); + +export const layer = Layer.effect(PreviewAutomationBroker, make); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts new file mode 100644 index 000000000000..6013b1cac9e9 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import type { + PreviewAutomationOperation, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; + +const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( + operation: PreviewAutomationOperation, + input: unknown, + timeoutMs?: number, +): Effect.fn.Return< + A, + import("@t3tools/contracts").PreviewAutomationError, + McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker +> { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + return yield* broker.invoke({ + scope, + operation, + input, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); +}); + +const handlers = { + preview_status: () => invoke("status", {}), + preview_open: (input) => + invoke("open", { + ...input, + show: input.show ?? true, + reuseExistingTab: input.reuseExistingTab ?? true, + }), + preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), + preview_snapshot: () => invoke("snapshot", {}), + preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), + preview_type: (input) => invoke("type", input, input.timeoutMs).pipe(Effect.as(null)), + preview_press: (input) => invoke("press", input).pipe(Effect.as(null)), + preview_scroll: (input) => invoke("scroll", input).pipe(Effect.as(null)), + preview_evaluate: (input) => + invoke("evaluate", input).pipe(Effect.map((result) => result ?? null)), + preview_wait_for: (input) => + invoke("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), + preview_recording_start: () => invoke("recordingStart", {}), + preview_recording_stop: () => invoke("recordingStop", {}), +} satisfies Parameters[0]; + +const { preview_snapshot, ...standardHandlers } = handlers; + +export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers); + +export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ + preview_snapshot, +}); + +export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts new file mode 100644 index 000000000000..1347e0db0ec4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { PreviewToolkit } from "./tools.ts"; + +const schemaHasDescription = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record; + if (typeof record.description === "string" && record.description.length > 0) return true; + return [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .some((members) => members.some(schemaHasDescription)); +}; + +it("exports provider-compatible object schemas with described parameters", () => { + for (const tool of Object.values(PreviewToolkit.tools)) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect( + tool.description?.length ?? 0, + `${tool.name} should have a useful description`, + ).toBeGreaterThan(40); + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { + expect( + schemaHasDescription(fieldSchema), + `${tool.name}.${field} should explain what data the agent must pass`, + ).toBe(true); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts new file mode 100644 index 000000000000..fd2fedbb3696 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -0,0 +1,196 @@ +import { + PreviewAutomationClickInput, + PreviewAutomationError, + PreviewAutomationEvaluateInput, + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationPressInput, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + PreviewAutomationBroker.PreviewAutomationBroker, +]; + +const browserTool = (tool: T): T => + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +const safeBrowserTool = (tool: T): T => + browserTool(tool).annotate(Tool.Destructive, false) as T; + +const readonlyBrowserTool = (tool: T): T => + safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; + +export const PreviewStatusTool = Tool.make("preview_status", { + description: + "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, +}) + .annotate(Tool.Title, "Get preview status") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const PreviewOpenTool = browserTool( + Tool.make("preview_open", { + description: + "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", + parameters: PreviewAutomationOpenInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Open browser preview") + .annotate(Tool.Destructive, false), +); + +export const PreviewNavigateTool = safeBrowserTool( + Tool.make("preview_navigate", { + description: + "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", + parameters: PreviewAutomationNavigateInput, + success: PreviewAutomationStatus, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Navigate browser preview"), +); + +export const PreviewSnapshotTool = readonlyBrowserTool( + Tool.make("preview_snapshot", { + description: + "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", + success: PreviewAutomationSnapshot, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Inspect browser page"), +); + +export const PreviewClickTool = browserTool( + Tool.make("preview_click", { + description: + "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", + parameters: PreviewAutomationClickInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Click preview page"), +); + +export const PreviewTypeTool = browserTool( + Tool.make("preview_type", { + description: + "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", + parameters: PreviewAutomationTypeInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Type into preview page"), +); + +export const PreviewPressTool = browserTool( + Tool.make("preview_press", { + description: + "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", + parameters: PreviewAutomationPressInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Press key in preview page"), +); + +export const PreviewScrollTool = safeBrowserTool( + Tool.make("preview_scroll", { + description: + "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", + parameters: PreviewAutomationScrollInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Scroll preview page"), +); + +export const PreviewEvaluateTool = browserTool( + Tool.make("preview_evaluate", { + description: + "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", + parameters: PreviewAutomationEvaluateInput, + success: Schema.Unknown, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Evaluate JavaScript in preview"), +); + +export const PreviewWaitForTool = readonlyBrowserTool( + Tool.make("preview_wait_for", { + description: + "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", + parameters: PreviewAutomationWaitForInput, + success: Schema.Null, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Wait for preview page condition"), +); + +export const PreviewRecordingStartTool = safeBrowserTool( + Tool.make("preview_recording_start", { + description: + "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", + success: PreviewAutomationRecordingStatus, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Start browser recording"), +); + +export const PreviewRecordingStopTool = safeBrowserTool( + Tool.make("preview_recording_stop", { + description: "Stop the active browser recording and save it as a local evidence artifact.", + success: PreviewAutomationRecordingArtifact, + failure: PreviewAutomationError, + dependencies, + }).annotate(Tool.Title, "Stop browser recording"), +); + +export const PreviewToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewSnapshotTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, +); + +export const PreviewStandardToolkit = Toolkit.make( + PreviewStatusTool, + PreviewOpenTool, + PreviewNavigateTool, + PreviewClickTool, + PreviewTypeTool, + PreviewPressTool, + PreviewScrollTool, + PreviewEvaluateTool, + PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, +); + +export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool); diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts new file mode 100644 index 000000000000..300a50fe3308 --- /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 95263866d809..11463cc1d85c 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 b704804c9630..000000000000 --- 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 3c9ec2cdfa52..707c87c43c99 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"; @@ -56,8 +55,8 @@ import { } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; -import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.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( - WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), + WorkspaceEntries.layer.pipe( + 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 40291ec4f66b..3ba244ddf2c4 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"; @@ -34,7 +34,7 @@ import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -81,9 +81,9 @@ 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; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const appendRevertFailureActivity = (input: { @@ -252,9 +252,9 @@ const make = Effect.gen(function* () { checkpointRef: targetCheckpointRef, }); - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects files created or deleted during this turn. - yield* workspaceEntries.invalidate(input.cwd); + yield* workspaceEntries.refresh(input.cwd); const files = yield* checkpointStore .diffCheckpoints({ @@ -690,9 +690,9 @@ const make = Effect.gen(function* () { return; } - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. - yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); + yield* workspaceEntries.refresh(sessionRuntime.value.cwd); const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 56876ec148ed..b2ef0fed0f96 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), @@ -680,7 +680,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), @@ -785,7 +785,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), ), @@ -928,7 +928,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/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 7db66846462d..300d1526bb9a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -12,7 +12,6 @@ import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; -import * as WebPushNotifier from "../../push/WebPushNotifier.ts"; describe("OrchestrationReactor", () => { let runtime: ManagedRuntime.ManagedRuntime | null = null; @@ -74,17 +73,6 @@ describe("OrchestrationReactor", () => { }, }), ), - Layer.provideMerge( - Layer.succeed(WebPushNotifier.WebPushNotifier, { - getStatus: () => Effect.succeed({ enabled: false }), - subscribe: () => Effect.succeed({ ok: true }), - unsubscribe: () => Effect.succeed({ ok: true }), - start: () => { - started.push("web-push-notifier"); - return Effect.void; - }, - }), - ), ), ); @@ -98,7 +86,6 @@ describe("OrchestrationReactor", () => { "checkpoint-reactor", "thread-deletion-reactor", "agent-awareness-relay", - "web-push-notifier", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index d8294c5805f3..fb7543e31af0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,7 +10,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; -import * as WebPushNotifier from "../../push/WebPushNotifier.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; @@ -18,7 +17,6 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; - const webPushNotifier = yield* WebPushNotifier.WebPushNotifier; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); @@ -26,7 +24,6 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); yield* agentAwarenessRelay.start(); - yield* webPushNotifier.start(); }); return { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 5a997de36698..0999000ed4f9 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 a2d1d965053a..be642379e148 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), ), @@ -1511,7 +1510,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 ee0dac84813d..7e36607cbcb9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -48,7 +48,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.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, @@ -272,7 +272,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + 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 77f9a2ed9049..ce464565dc5f 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"; @@ -59,7 +59,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; 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); @@ -71,7 +71,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 => { @@ -107,11 +107,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(); }); @@ -147,7 +147,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); @@ -335,11 +336,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 layer = ProviderCommandReactorLive.pipe( @@ -348,9 +349,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 649555902356..001ba3889496 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"; @@ -198,7 +198,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; } @@ -213,24 +213,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 4bbf5ca21499..7d8a24069a32 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 95d29e3d6d29..bed166eba45d 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/os-jank.ts b/apps/server/src/os-jank.ts index 93a40ae7e197..bc72758bc718 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,21 +1,15 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { - readPathFromLoginShell, - readEnvironmentFromWindowsShell, - resolveWindowsEnvironment, - type CommandAvailabilityOptions, - type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, + readPathFromLoginShell, readPathFromLaunchctl, + resolveWindowsEnvironment, } from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options?: CommandAvailabilityOptions, -) => boolean; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export function fixPath( - options: { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - readPath?: typeof readPathFromLoginShell; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): void { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const logWarning = options.logWarning ?? logPathHydrationWarning; - const readPath = options.readPath ?? readPathFromLoginShell; - - try { - if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { - readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } - } - return; +function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL)) { + try { + shellPath = readPathFromLoginShell(shell); + } catch (error) { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } - if (platform !== "darwin" && platform !== "linux") return; + if (shellPath) break; + } - let shellPath: string | undefined; - for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - shellPath = readPath(shell); - } catch (error) { - logWarning(`Failed to read PATH from login shell ${shell}.`, error); - } + const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; + } +} - if (shellPath) { - break; - } - } +export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< + void, + never, + FileSystem.FileSystem | Path.Path +> { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; - const launchctlPath = - platform === "darwin" && !shellPath - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; + if (platform === "win32") { + const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + return {} as Partial; + }), + ), + ); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } } - } catch (error) { - logWarning("Failed to hydrate PATH from the user environment.", error); + return; } -} + + if (platform !== "darwin" && platform !== "linux") return; + + yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + }), + ), + ); +}); export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; diff --git a/apps/server/src/pathExpansion.test.ts b/apps/server/src/pathExpansion.test.ts index a6f004d4e6f4..cc7c85786da6 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 170d83c54d04..bacdaece0b1c 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 000000000000..e54c977e7ab7 --- /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 ab84e3fa0416..545688e38228 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 000000000000..680a362e20a3 --- /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 2a3d7aff189c..03edaec77d63 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 9d2760d14490..000000000000 --- 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 9ee5c82bb53d..52e4f8f74088 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/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 3bc1ec4d2d2a..dfd338b7159f 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; @@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = { }; type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; + layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts index 43023abf60a1..ce52c36d84ce 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 6b91b5bd07ba..16d5762a1fee 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 000000000000..a3475d2f190b --- /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 000000000000..f7425200fd1d --- /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/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts deleted file mode 100644 index c8745982d291..000000000000 --- 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 c08956bdd716..000000000000 --- 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 125f4fa5bbf2..000000000000 --- 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 new file mode 100644 index 000000000000..acdfe54301eb --- /dev/null +++ b/apps/server/src/preview/Manager.test.ts @@ -0,0 +1,286 @@ +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"; + +import * as PreviewManager from "./Manager.ts"; + +const DRAIN_LIMIT = 100; + +interface EventCollector { + /** Drain everything published since the last call (or since subscribe). */ + readonly drain: Effect.Effect>; +} + +/** + * Each `it.effect` shares the live PreviewManager layer across the whole + * `it.layer` block, so tests that assert per-thread counts must use a unique + * thread id to avoid bleeding state from earlier tests. + */ +let nextThreadId = 0; +const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); + +/** + * Subscribe to the manager's event stream BEFORE the test publishes. We + * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so + * no event can land between subscribe and the consumer drain. + */ +const collectEvents = Effect.gen(function* () { + const manager = yield* PreviewManager.PreviewManager; + const subscription = yield* manager.subscribeEvents; + const collector: EventCollector = { + drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), + }; + return collector; +}).pipe(Effect.withSpan("preview.test.collectEvents")); + +it.layer(PreviewManager.layer)("PreviewManager", (it) => { + it.effect("opens a session and emits opened with normalized URL", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); + expect(snapshot.tabId.startsWith("tab_")).toBe(true); + expect(snapshot.navStatus._tag).toBe("Loading"); + if (snapshot.navStatus._tag === "Loading") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); + } + + const events = yield* collector.drain; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("opened"); + if (events[0]?.type === "opened") { + expect(events[0].tabId).toBe(snapshot.tabId); + } + }), + ); + + it.effect("opens an Idle tab when no URL is supplied", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const snapshot = yield* manager.open({ threadId }); + expect(snapshot.navStatus._tag).toBe("Idle"); + }), + ); + + it.effect("treats bare hosts as https", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const snapshot = yield* manager.open({ threadId, url: "example.com" }); + if (snapshot.navStatus._tag === "Loading") { + expect(snapshot.navStatus.url).toBe("https://example.com/"); + } + }), + ); + + it.effect("rejects empty URL with PreviewInvalidUrlError", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + 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/); + }), + ); + + it.effect("navigate updates snapshot and emits navigated", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const snapshot = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "http://localhost:5173/about", + resolvedTitle: "About", + }); + + expect(snapshot.navStatus._tag).toBe("Success"); + if (snapshot.navStatus._tag === "Success") { + expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); + expect(snapshot.navStatus.title).toBe("About"); + } + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); + }), + ); + + it.effect("navigate fails for unknown tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const error = yield* Effect.flip( + manager.navigate({ + threadId, + tabId: "tab_missing", + url: "http://localhost:5173", + }), + ); + expect(error._tag).toBe("PreviewSessionLookupError"); + }), + ); + + it.effect("reportStatus emits failed for LoadFailed nav", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { + _tag: "LoadFailed", + url: "http://localhost:5173", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }, + canGoBack: false, + canGoForward: false, + }); + + const events = yield* collector.drain; + const failed = events.find((e) => e.type === "failed"); + expect(failed?.type).toBe("failed"); + if (failed?.type === "failed") { + expect(failed.code).toBe(-105); + expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); + } + }), + ); + + it.effect("close removes the session and emits closed", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId }); + + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + const events = yield* collector.drain; + const closed = events.find((e) => e.type === "closed"); + expect(closed?.type).toBe("closed"); + }), + ); + + it.effect("close is idempotent for unknown threads", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.close({ threadId }); + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(0); + }), + ); + + it.effect("list returns every snapshot for the thread sorted by updatedAt", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); + const result = yield* manager.list({ threadId }); + expect(result.sessions).toHaveLength(2); + const ids = result.sessions.map((s) => s.tabId); + expect(ids).toContain(first.tabId); + expect(ids).toContain(second.tabId); + }), + ); + + it.effect("open creates an independent tab on every call", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const collector = yield* collectEvents; + + const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); + + expect(a.tabId).not.toBe(b.tabId); + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(2); + + const events = yield* collector.drain; + expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); + + it.effect("close with mismatching tabId is a no-op", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.close({ threadId, tabId: "tab_missing" }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions).toHaveLength(1); + }), + ); + + it.effect("close with explicit tabId removes only that tab", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); + const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); + + yield* manager.close({ threadId, tabId: a.tabId }); + + const list = yield* manager.list({ threadId }); + expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); + }), + ); + + it.effect("multiple subscribers receive every event independently", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const aSub = yield* manager.subscribeEvents; + const bSub = yield* manager.subscribeEvents; + + yield* manager.open({ threadId, url: "http://localhost:5173" }); + yield* manager.open({ threadId, url: "http://localhost:3000" }); + + const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); + const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); + expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); + }), + ); +}); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts new file mode 100644 index 000000000000..fe3557c157f9 --- /dev/null +++ b/apps/server/src/preview/Manager.ts @@ -0,0 +1,371 @@ +/** + * In-memory PreviewManager implementation. + * + * Sessions are keyed by `(threadId, tabId)`; a single thread can host + * multiple tabs (browser-style). `open` always creates a new tab — tab + * lifecycle is owned by the renderer. + * + * Events are published via Effect's `PubSub`, so subscriber failures are + * isolated from the publishing call (a closed WS subscriber queue cannot + * fail an in-progress `navigate()`). + */ +import { + type PreviewCloseInput, + type PreviewEvent, + type PreviewError, + PreviewInvalidUrlError, + type PreviewListInput, + type PreviewListResult, + type PreviewNavigateInput, + type PreviewOpenInput, + type PreviewRefreshInput, + type PreviewReportStatusInput, + PreviewSessionLookupError, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { + isPreviewUrlNormalizationError, + newPreviewTabId, + normalizePreviewUrl, +} from "@t3tools/shared/preview"; +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< + 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; + readonly tabId: string; + readonly snapshot: PreviewSessionSnapshot; +} + +interface ManagerState { + /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ + readonly sessions: ReadonlyMap; +} + +const initialState: ManagerState = { sessions: new Map() }; + +const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; + +const sessionsForThread = ( + state: ManagerState, + threadId: string, +): ReadonlyArray => { + const out: PreviewSessionState[] = []; + for (const session of state.sessions.values()) { + if (session.threadId === threadId) out.push(session); + } + return out; +}; + +const normalizeUrl = (rawUrl: string): Effect.Effect => + Effect.try({ + try: () => normalizePreviewUrl(rawUrl), + 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)); + +const buildLoadingSnapshot = (input: { + readonly threadId: string; + readonly tabId: string; + readonly url: string; + readonly title: string; + readonly updatedAt: string; +}): PreviewSessionSnapshot => ({ + threadId: input.threadId, + tabId: input.tabId, + navStatus: { _tag: "Loading", url: input.url, title: input.title }, + canGoBack: false, + canGoForward: false, + updatedAt: input.updatedAt, +}); + +const buildIdleSnapshot = (input: { + readonly threadId: string; + readonly tabId: string; + readonly updatedAt: string; +}): PreviewSessionSnapshot => ({ + threadId: input.threadId, + tabId: input.tabId, + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: input.updatedAt, +}); + +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 + // their own queues downstream. + const eventsPubSub = yield* PubSub.unbounded(); + const events: Stream.Stream = Stream.fromPubSub(eventsPubSub); + + /** + * Atomic read-modify-write over the session for `(threadId, tabId)`. The + * mutator runs under the SynchronizedRef so concurrent writers cannot + * interleave. Lookup failures travel through the modify result so both + * branches yield the same `[A, S]` shape `modifyEffect` requires. + * + * The event is published INSIDE the lock so observers see events in the + * same order as the underlying state transitions. Publishing an unbounded + * PubSub is non-blocking, so this is cheap. + */ + const mutateExistingSession = ( + threadId: string, + tabId: string, + mutator: ( + session: PreviewSessionState, + ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, + ): Effect.Effect => { + type ModifyResult = + | { kind: "fail"; error: PreviewSessionLookupError } + | { kind: "ok"; result: R }; + + return SynchronizedRef.modifyEffect(stateRef, (state) => { + const session = state.sessions.get(compositeKey(threadId, tabId)); + if (!session) { + return Effect.succeed([ + { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) }, + state, + ] as readonly [ModifyResult, ManagerState]); + } + return mutator(session).pipe( + Effect.flatMap( + Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { + if (emit) yield* PubSub.publish(eventsPubSub, emit); + const sessions = new Map(state.sessions); + sessions.set(compositeKey(threadId, tabId), next); + return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ + ModifyResult, + ManagerState, + ]; + }), + ), + ); + }).pipe( + Effect.flatMap((modify) => + modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result), + ), + ); + }; + + 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, + snapshot, + }); + return { sessions }; + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", + threadId: input.threadId, + tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + return snapshot; + }, + ); + + const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")( + function* (input) { + const url = yield* normalizeUrl(input.url); + return yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.navigateSession")(function* (session) { + const updatedAt = yield* currentIsoTimestamp; + const previousTitle = + session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; + const resolvedTitle = input.resolvedTitle ?? previousTitle; + const snapshot: PreviewSessionSnapshot = { + threadId: session.threadId, + tabId: session.tabId, + navStatus: { _tag: "Success", url, title: resolvedTitle }, + canGoBack: session.snapshot.canGoBack, + canGoForward: session.snapshot.canGoForward, + updatedAt, + }; + return { + next: { ...session, snapshot }, + emit: { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }, + result: snapshot, + }; + }), + ); + }, + ); + + const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn( + "PreviewManager.reportStatus", + )(function* (input) { + yield* mutateExistingSession( + input.threadId, + input.tabId, + Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { + const updatedAt = yield* currentIsoTimestamp; + const snapshot: PreviewSessionSnapshot = { + threadId: session.threadId, + tabId: session.tabId, + navStatus: input.navStatus, + canGoBack: input.canGoBack, + canGoForward: input.canGoForward, + updatedAt, + }; + const emit: PreviewEvent = + input.navStatus._tag === "LoadFailed" + ? { + type: "failed", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + url: input.navStatus.url, + title: input.navStatus.title, + code: input.navStatus.code, + description: input.navStatus.description, + } + : { + type: "navigated", + threadId: session.threadId, + tabId: session.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }; + return { + next: { ...session, snapshot }, + emit, + result: undefined as void, + }; + }), + ); + }); + + 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. + yield* mutateExistingSession(input.threadId, input.tabId, (session) => + Effect.succeed({ next: session, emit: null, result: undefined as void }), + ); + }, + ); + + 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, + }); + } + }, + ); + + 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 PreviewManager.of({ + open, + navigate, + reportStatus, + refresh, + close, + list, + events, + subscribeEvents: PubSub.subscribe(eventsPubSub), + }); +}).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 new file mode 100644 index 000000000000..69b5729164da --- /dev/null +++ b/apps/server/src/preview/PortScanner.test.ts @@ -0,0 +1,157 @@ +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 * 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 * as ProcessRunner from "../processRunner.ts"; +import * as PortScanner from "./PortScanner.ts"; +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 => + Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.once("error", () => { + resume(Effect.succeed(null)); + }); + server.listen(port, "127.0.0.1", () => { + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.close(); + }); + }); + +const closeServer = (server: NodeNet.Server): Effect.Effect => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + +const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( + ports: ReadonlyArray, +) { + for (const port of ports) { + const server = yield* openServer(port); + if (server !== null) return { port, server }; + } + return yield* Effect.die( + new Error("No common development port was available for the preview scanner test"), + ); +}); + +const commonDevServer = Effect.acquireRelease( + openCommonDevServer(PortScanner.COMMON_DEV_PORTS), + ({ server }) => closeServer(server), +); + +/** + * Integration tests against a real TCP listener. We provide the Windows host + * platform so the tests exercise the TCP-probe fallback without depending on + * `lsof` being installed. + */ +effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { + it.effect( + "scan() returns a server we just opened on a curated dev port", + Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { + const { port } = yield* commonDevServer; + const scanner = yield* PortScanner.PortDiscovery; + const result = yield* scanner.scan(); + const found = result.find((server) => server.port === port); + expect(found).toBeDefined(); + expect(found?.host).toBe("localhost"); + }), + ); + + it.effect( + "retain drives an immediate broadcast to subscribers", + Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { + const { port } = yield* commonDevServer; + const received: number[] = []; + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.subscribe((servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), + ); + yield* scanner.retain; + expect(received).toContain(port); + }), + ); +}); + +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 new file mode 100644 index 000000000000..c306fca2b337 --- /dev/null +++ b/apps/server/src/preview/PortScanner.ts @@ -0,0 +1,390 @@ +/** + * In-process PortScanner implementation. + * + * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a + * stable line-prefixed field format; this is the only `lsof` flag set we rely + * on). + * + * Windows / lsof missing: checks a curated list of common dev ports through + * the shared Net service. + * + * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber + * polls forever, but each tick is a no-op when the retain count is zero. + */ +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 * 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 * as ProcessRunner from "../processRunner.ts"; + +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, +]); + +const POLL_INTERVAL = Duration.seconds(3); +const LSOF_TIMEOUT_MS = 5_000; +const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; + +type Listener = (servers: ReadonlyArray) => Effect.Effect; + +interface ScannerState { + readonly lastSnapshot: ReadonlyArray; + readonly listeners: ReadonlySet; + readonly terminalProcesses: ReadonlyMap< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet; + } + >; + readonly retainCount: number; +} + +interface TerminalProcessOwner { + readonly threadId: ThreadId; + readonly terminalId: string; +} + +const terminalOwnerKey = (owner: { + readonly threadId: string; + readonly terminalId: string; +}): string => `${owner.threadId}\u0000${owner.terminalId}`; + +const parseLsofOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + let pid: number | null = null; + let processName: string | null = null; + + for (const line of raw.split("\n")) { + if (line.length === 0) continue; + const tag = line.charAt(0); + const value = line.slice(1); + if (tag === "p") { + const parsed = Number.parseInt(value, 10); + pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; + processName = null; + continue; + } + if (tag === "c") { + processName = value.trim() || null; + continue; + } + if (tag === "n") { + const portMatch = parsePortFromLsofName(value); + if (portMatch == null) continue; + const url = `http://localhost:${portMatch}`; + const key = `localhost:${portMatch}`; + if (seen.has(key)) continue; + seen.set(key, { + host: "localhost", + port: portMatch, + url, + processName, + pid, + terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + }); + } + } + + return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); +}; + +const parsePortFromLsofName = (name: string): number | null => { + // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", + // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. + const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; + if (trimmed.length === 0) return null; + const lastColon = trimmed.lastIndexOf(":"); + if (lastColon < 0) return null; + const hostPart = trimmed.slice(0, lastColon); + const portPart = trimmed.slice(lastColon + 1); + if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; + const port = Number.parseInt(portPart, 10); + if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; + return port; +}; + +const parseWindowsListenerOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + for (const line of raw.split(/\r?\n/g)) { + const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); + const host = hostRaw?.trim() ?? ""; + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; + const port = Number(portRaw); + const pid = Number(pidRaw); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; + const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; + if (seen.has(port)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: processNameRaw?.trim() || null, + pid: normalizedPid, + terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); +}; + +const serversEqual = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + const a = left[i]; + const b = right[i]; + if (!a || !b) return false; + if ( + a.host !== b.host || + a.port !== b.port || + a.url !== b.url || + a.processName !== b.processName || + a.pid !== b.pid || + a.terminal?.threadId !== b.terminal?.threadId || + a.terminal?.terminalId !== b.terminal?.terminalId + ) { + return false; + } + } + return true; +}; + +export const make = Effect.gen(function* PortDiscoveryMake() { + const net = yield* Net.NetService; + const processRunner = yield* ProcessRunner.ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; + const stateRef = yield* Ref.make({ + lastSnapshot: [], + listeners: new Set(), + terminalProcesses: new Map(), + retainCount: 0, + }); + + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { + const results = yield* Effect.forEach( + COMMON_DEV_PORTS, + (port) => + net.isPortAvailableOnLoopback(port).pipe( + Effect.map((available) => ({ + port, + listening: !available, + })), + ), + { concurrency: "unbounded" }, + ); + return results + .filter((result) => result.listening) + .map((result) => ({ + host: "localhost", + port: result.port, + url: `http://localhost:${result.port}`, + processName: null, + pid: null, + terminal: null, + })); + }); + + 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(); + for (const registration of state.terminalProcesses.values()) { + for (const processId of registration.processIds) { + terminalByProcessId.set(processId, registration.owner); + } + } + 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 + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), + 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", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.catchTags({ + ProcessSpawnError: recoverLsofProbeFailure, + ProcessStdinError: recoverLsofProbeFailure, + ProcessOutputLimitError: recoverLsofProbeFailure, + ProcessReadError: recoverLsofProbeFailure, + ProcessTimeoutError: recoverLsofProbeFailure, + }), + ); + if (lsofResult !== null) return lsofResult; + return yield* probeCommonPorts(); + }); + + const broadcast = Effect.fn("PortDiscovery.broadcast")(function* ( + servers: ReadonlyArray, + ) { + const listeners = (yield* Ref.get(stateRef)).listeners; + yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); + }); + + const pollTick = Effect.fn("PortDiscovery.pollTick")( + function* () { + if ((yield* Ref.get(stateRef)).retainCount <= 0) return; + const next = yield* scanOnce(); + const changed = yield* Ref.modify(stateRef, (state) => + serversEqual(state.lastSnapshot, next) + ? [false, state] + : [true, { ...state, lastSnapshot: next }], + ); + if (changed) yield* broadcast(next); + }, + Effect.catchCause((cause: Cause.Cause) => + Effect.logWarning("preview port scan failed", Cause.pretty(cause)), + ), + ); + + // Single layer-scoped polling fiber. Ticks are no-ops when no client is + // currently retained, so the cost is one Ref.get every POLL_INTERVAL. + yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + + const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { + const wasIdle = yield* Ref.modify(stateRef, (state) => [ + state.retainCount === 0, + { ...state, retainCount: state.retainCount + 1 }, + ]); + if (wasIdle) { + // Run an immediate scan + broadcast so the new retainer doesn't have + // to wait up to POLL_INTERVAL for the first emission. + yield* pollTick(); + } + }); + + const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(acquireRetention(), () => + Ref.update(stateRef, (state) => ({ + ...state, + retainCount: Math.max(0, state.retainCount - 1), + })), + ); + + const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( + (listener) => + Effect.acquireRelease( + Ref.update(stateRef, (state) => ({ + ...state, + listeners: new Set([...state.listeners, listener]), + })), + () => + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }), + ), + ); + + 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: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn( + "PortDiscovery.unregisterTerminal", + )(function* (input) { + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + terminalProcesses.delete(terminalOwnerKey(input)); + return { ...state, terminalProcesses }; + }); + }); + + return PortDiscovery.of({ + scan: scanOnce, + subscribe, + retain, + registerTerminalProcesses, + unregisterTerminal, + }); +}).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 75e76b5e8e29..43ca40e9c7c8 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,9 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import * as Crypto from "effect/Crypto"; +import * as ConfigProvider from "effect/ConfigProvider"; 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 Path from "effect/Path"; @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - isCommandAvailable, - launchBrowser, - launchEditorProcess, - resolveAvailableEditors, - resolveBrowserLaunch, - resolveEditorLaunch, -} from "./externalLauncher.ts"; - -function encodeUtf16LeBase64(input: string): string { - const bytes = new Uint8Array(input.length * 2); - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - bytes[index * 2] = code & 0xff; - bytes[index * 2 + 1] = code >>> 8; - } - return Encoding.encodeBase64(bytes); -} +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -49,756 +32,137 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { }); } -it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { - it.effect("returns commands for command-based editors", () => - Effect.gen(function* () { - const antigravityLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "antigravity" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); - - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); - - const traeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLaunch, { - command: "trae", - args: ["/tmp/workspace"], - }); - - const kiroLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(kiroLaunch, { - command: "kiro", - args: ["ide", "/tmp/workspace"], - }); - - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); - - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); - - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); - - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); - - const ideaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLaunch, { - command: "idea", - args: ["/tmp/workspace"], - }); - - const aquaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "aqua" }, - "darwin", - ); - assert.deepEqual(aquaLaunch, { - command: "aqua", - args: ["/tmp/workspace"], - }); - - const clionLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "clion" }, - "darwin", - ); - assert.deepEqual(clionLaunch, { - command: "clion", - args: ["/tmp/workspace"], - }); - - const datagripLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "datagrip" }, - "darwin", - ); - assert.deepEqual(datagripLaunch, { - command: "datagrip", - args: ["/tmp/workspace"], - }); - - const dataspellLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "dataspell" }, - "darwin", - ); - assert.deepEqual(dataspellLaunch, { - command: "dataspell", - args: ["/tmp/workspace"], - }); - - const golandLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "goland" }, - "darwin", - ); - assert.deepEqual(golandLaunch, { - command: "goland", - args: ["/tmp/workspace"], - }); - - const phpstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "phpstorm" }, - "darwin", - ); - assert.deepEqual(phpstormLaunch, { - command: "phpstorm", - args: ["/tmp/workspace"], - }); - - const pycharmLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "pycharm" }, - "darwin", - ); - assert.deepEqual(pycharmLaunch, { - command: "pycharm", - args: ["/tmp/workspace"], - }); - - const riderLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rider" }, - "darwin", - ); - assert.deepEqual(riderLaunch, { - command: "rider", - args: ["/tmp/workspace"], - }); - - const rubymineLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rubymine" }, - "darwin", - ); - assert.deepEqual(rubymineLaunch, { - command: "rubymine", - args: ["/tmp/workspace"], - }); - - const rustroverLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rustrover" }, - "darwin", - ); - assert.deepEqual(rustroverLaunch, { - command: "rustrover", - args: ["/tmp/workspace"], - }); - - const webstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "webstorm" }, - "darwin", - ); - assert.deepEqual(webstormLaunch, { - command: "webstorm", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("applies launch-style-specific navigation arguments", () => - Effect.gen(function* () { - const lineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineOnly, { - command: "cursor", - args: ["--goto", "/tmp/workspace/AGENTS.md:48"], - }); - - const lineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineAndColumn, { - command: "cursor", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const traeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLineAndColumn, { - command: "trae", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const kiroLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(kiroLineAndColumn, { - command: "kiro", - args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLineAndColumn, { - command: "code", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLineAndColumn, { - command: "code-insiders", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodiumLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLineAndColumn, { - command: "codium", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineAndColumn, { - command: "zed", - args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineOnly, { - command: "zed", - args: ["/tmp/workspace/AGENTS.md:48"], - }); - - const ideaLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineOnly, { - command: "idea", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - - const ideaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineAndColumn, { - command: "idea", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const aquaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" }, - "darwin", - ); - assert.deepEqual(aquaLineAndColumn, { - command: "aqua", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const clionLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" }, - "darwin", - ); - assert.deepEqual(clionLineAndColumn, { - command: "clion", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const datagripLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" }, - "darwin", - ); - assert.deepEqual(datagripLineAndColumn, { - command: "datagrip", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const dataspellLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" }, - "darwin", - ); - assert.deepEqual(dataspellLineAndColumn, { - command: "dataspell", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const golandLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" }, - "darwin", - ); - assert.deepEqual(golandLineAndColumn, { - command: "goland", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const phpstormLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" }, - "darwin", - ); - assert.deepEqual(phpstormLineAndColumn, { - command: "phpstorm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const pycharmLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" }, - "darwin", - ); - assert.deepEqual(pycharmLineAndColumn, { - command: "pycharm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const riderLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" }, - "darwin", - ); - assert.deepEqual(riderLineAndColumn, { - command: "rider", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rubymineLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" }, - "darwin", - ); - assert.deepEqual(rubymineLineAndColumn, { - command: "rubymine", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rustroverLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" }, - "darwin", - ); - assert.deepEqual(rustroverLineAndColumn, { - command: "rustrover", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const webstormLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, - "darwin", - ); - assert.deepEqual(webstormLineOnly, { - command: "webstorm", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - }), - ); - - it.effect("falls back to zeditor when zed is not installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); - - assert.deepEqual(result, { - command: "zeditor", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("falls back to the primary command when no alias is installed", () => - Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: "", - }); - assert.deepEqual(result, { - command: "zed", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("maps file-manager editor to OS open commands", () => - Effect.gen(function* () { - const launch1 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(launch1, { - command: "open", - args: ["/tmp/workspace"], - }); - - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - { PATH: "" }, - ); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - { PATH: "" }, - ); - assert.deepEqual(launch3, { - command: "xdg-open", - args: ["/tmp/workspace"], - }); - }), - ); -}); - -it("resolveBrowserLaunch maps default browser launchers by platform", () => { - const target = "https://example.com/some path?name=o'hara"; - - assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open"); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, { - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); - - const windows = resolveBrowserLaunch(target, "win32", { - SYSTEMROOT: "C:\\Windows", - }); - assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - assert.deepEqual(windows.args, [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodeUtf16LeBase64( - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", +const testLayer = (input: { + readonly platform: NodeJS.Platform; + readonly env?: Record; + readonly resolveExecutable?: (command: string) => string | undefined; + readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; + readonly onUnref?: () => void; +}) => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + input.onSpawn?.(command); + return makeMockDetachedHandle(input.onUnref); + }), ), - ]); - assert.deepEqual(windows.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); -}); - -it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - }); - assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); - assert.equal(launch.options.detached, true); -}); - -it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - SSH_CONNECTION: "client server", - }); - assert.equal(launch.command, "xdg-open"); -}); - -it.layer(NodeServices.layer)("launchBrowser", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchBrowser("https://example.com").pipe( - Effect.provide(spawnerLayer), - Effect.result, - ); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - const expectedLaunch = resolveBrowserLaunch("https://example.com"); - assert.equal(spawnedCommand.command, expectedLaunch.command); - assert.deepEqual(spawnedCommand.args, expectedLaunch.args); - assert.deepEqual(spawnedCommand.options, expectedLaunch.options); - assert.equal(didUnref, true); - }), - ); -}); - -it.layer(NodeServices.layer)("launchEditorProcess", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - const expectedArgs = ["-e", "process.exit(0)"]; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchEditorProcess({ - command: process.execPath, - args: expectedArgs, - }).pipe(Effect.provide(spawnerLayer), Effect.result); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - assert.equal(spawnedCommand.command, process.execPath); - assert.deepEqual( - spawnedCommand.args, - process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, - ); - assert.deepEqual(spawnedCommand.options, { - detached: true, - shell: process.platform === "win32", - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - assert.equal(didUnref, true); - }), - ); - - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); - const result = yield* launchEditorProcess({ - command: `t3code-no-such-command-${yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - )}`, - args: [], - }).pipe(Effect.provide(spawnerLayer), Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); - - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); - - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }), ); - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }), + return Layer.mergeAll( + ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed( + SpawnExecutableResolution, + (command) => input.resolveExecutable?.(command) ?? command, + ), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); - - it.effect("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), +}; + +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.ExternalLauncher; + + yield* launcher.launchBrowser("https://example.com/some path"); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["https://example.com/some path"]); + assert.equal(spawned.options.detached, true); + assert.equal(didUnref, true); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + onSpawn: (command) => { + spawned = command; + }, + onUnref: () => { + didUnref = true; + }, + }), + ), ); }); -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); - assert.deepEqual(editors, [ - "trae", - "kiro", - "vscode-insiders", - "vscodium", - "aqua", - "clion", - "datagrip", - "dataspell", - "goland", - "phpstorm", - "pycharm", - "rider", - "rubymine", - "rustrover", - "webstorm", - "file-manager", - ]); - }), - ); - - it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); - assert.deepEqual(editors, ["zed", "file-manager"]); - }), - ); - - it("omits file-manager when the platform opener is unavailable", () => { - const editors = resolveAvailableEditors("linux", { - PATH: "", - }); - assert.deepEqual(editors, []); - }); -}); +it.effect("launches an installed editor with platform-safe arguments", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ]); + assert.equal(spawned.options.shell, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("discovers editors through the service API", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects unknown editors through the service API", () => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const error = yield* launcher + .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) + .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 da19864dcf81..9c2f0e417d3d 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -9,26 +9,44 @@ import { EDITORS, ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; -import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; 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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Path from "effect/Path"; +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 }; -export { isCommandAvailable } from "@t3tools/shared/shell"; - interface EditorLaunch { + readonly editor: EditorId; + readonly target: string; readonly command: string; readonly args: ReadonlyArray; } @@ -61,6 +79,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const BrowserLaunchEnvConfig = Config.all({ + SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), + windir: Config.string("windir").pipe(Config.option), + WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), + WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), + SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), + SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), + container: Config.string("container").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { @@ -109,17 +157,17 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } -function resolveAvailableCommand( +const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, - options: CommandAvailabilityOptions = {}, -): Option.Option { + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } return Option.none(); -} +}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); @@ -135,7 +183,7 @@ function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`; } -function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string { +function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } @@ -145,7 +193,7 @@ function resolveWslPowerShellPath(): string { function shouldUseWindowsBrowserFromWsl( platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = {}, ): boolean { return ( platform === "linux" && @@ -184,10 +232,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -export function resolveBrowserLaunch( +function buildBrowserLaunch( target: string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = {}, ): ProcessLaunch { if (platform === "darwin") { return { @@ -212,63 +260,71 @@ export function resolveBrowserLaunch( }; } -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { +const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { const command = fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } } return available; -} +}); -/** - * ExternalLauncherShape - Service API for browser and editor launch actions. - */ -export interface ExternalLauncherShape { - /** - * 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; -} +const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( + target: string, +) { + const platform = yield* HostProcessPlatform; + const env = yield* readBrowserLaunchEnv; + return buildBrowserLaunch(target, platform, env); +}); + +const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + return yield* buildAvailableEditors(platform, env); +}); /** * 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 // ============================== -export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( +const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -276,30 +332,37 @@ export 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) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + yield* resolveAvailableCommand(editorDef.commands, env), () => 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); @@ -308,57 +371,93 @@ 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), ); }); -export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( +const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { - return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed"); + const launch = yield* resolveBrowserLaunch(target); + return yield* launchAndUnref( + launch, + (cause) => + new ExternalLauncherBrowserSpawnError({ + target, + command: launch.command, + args: launch.args, + cause, + }), + ); }); -export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( +const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, -): Effect.fn.Return { - if (!isCommandAvailable(launch.command)) { - return yield* new ExternalLauncherError({ - message: `Editor command not found: ${launch.command}`, +): Effect.fn.Return< + void, + ExternalLauncherError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const env = yield* readCommandLookupEnv; + if (!(yield* isCommandAvailable(launch.command, { env }))) { + return yield* new ExternalLauncherCommandNotFoundError({ + editor: launch.editor, + command: launch.command, }); } - const isWin32 = process.platform === "win32"; + const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); yield* launchAndUnref( { - command: launch.command, - args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], + command: spawnCommand.command, + args: spawnCommand.args, options: { detached: true, - shell: isWin32, + shell: spawnCommand.shell, stdin: "ignore", stdout: "ignore", 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; + + const provideCommandResolutionServices = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); - return { + return ExternalLauncher.of({ + resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + provideCommandResolutionServices( + Effect.flatMap(resolveEditorLaunch(input), (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ), ), - } 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 fae9ad574cf1..e264ba7849da 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -4,23 +4,22 @@ 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"; 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; readonly args: ReadonlyArray; + readonly options: { + readonly shell?: boolean | string; + }; }; // Accesses private properties of ChildProcessCommand for testing purposes @@ -57,22 +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, - shell: input.shell ?? false, }), ), Effect.provide( - ProcessRunnerLive.pipe( + ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ), ), @@ -108,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"], @@ -123,6 +124,82 @@ describe("runProcess", () => { }).pipe(Effect.provide(layer)); }); + it.effect("resolves and escapes Windows command shims before spawning", () => { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); + expect(command.args).toEqual([ + '^"repos^"', + '^"pr^"', + '^"list^"', + '^"--source-branch^"', + '^"feature^ ^&^ release^"', + ]); + expect(command.options.shell).toBe(true); + return makeHandle({ stdout: "[]" }); + }), + ); + + return runWith(spawner)({ + command: "az", + args: ["repos", "pr", "list", "--source-branch", "feature & release"], + env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), + Effect.map((result) => { + expect(result.stdout).toBe("[]"); + }), + ); + }); + + 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) }))); @@ -133,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"); }), ); @@ -158,7 +267,7 @@ describe("runProcess", () => { timeout: "2 seconds", }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); }), ); @@ -236,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); @@ -243,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"); }), ); @@ -280,19 +402,13 @@ describe("runProcess", () => { }); describe("isWindowsCommandNotFound", () => { - it("matches the localized German cmd.exe error text", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - - try { - expect( - isWindowsCommandNotFound( - 1, - "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", - ), - ).toBe(true); - } finally { - Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); - } - }); + it.effect("matches the localized German cmd.exe error text", () => + Effect.gen(function* () { + const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + expect(isCommandNotFound).toBe(true); + }), + ); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 45135bf9d2a6..c1ee2b2cb0c9 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -1,13 +1,16 @@ -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 { collectUint8StreamText, type CollectedUint8StreamText, @@ -24,7 +27,6 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - readonly shell?: boolean | string | undefined; /** * On timeout, return a synthetic timedOut result. * Partial stdout/stderr are not preserved. @@ -41,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; @@ -109,16 +160,20 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); } -export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { - if (process.platform !== "win32") return false; - if (code === 9009) return true; - return hasWindowsCommandNotFoundMessage(stderr); -} +export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( + function* (code: number | null, stderr: string) { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") return false; + if (code === 9009) return true; + return hasWindowsCommandNotFoundMessage(stderr); + }, +); 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; @@ -130,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, }), @@ -159,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, }), ); } @@ -215,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), }), ); @@ -231,18 +290,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; + const extendEnv = input.env !== undefined; + const spawnCommand = yield* resolveSpawnCommand( + input.command, + input.args, + input.env === undefined ? {} : { env: input.env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(input.command, [...input.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), ...(input.env !== undefined ? { env: input.env, - extendEnv: true, + extendEnv, } : {}), - ...(input.shell !== undefined ? { shell: input.shell } : {}), + shell: spawnCommand.shell, }), ) .pipe( @@ -250,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, }), ), @@ -278,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, @@ -288,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, @@ -304,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, }), @@ -322,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 c983aca4ba75..000000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ /dev/null @@ -1,77 +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"; - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive), - 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 cdfddd5438a0..000000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ /dev/null @@ -1,131 +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"; - -// 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 resolveIconHref = (projectCwd: string, href: string): string[] => { - const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - }; - - const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); - }; - - const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( - projectCwd: string, - candidates: ReadonlyArray, - ): Effect.fn.Return { - for (const candidate of candidates) { - if (!isPathWithinProject(projectCwd, candidate)) { - continue; - } - const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate; - } - } - return null; - }); - - const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { - for (const candidate of FAVICON_CANDIDATES) { - const resolved = path.join(cwd, candidate); - const existing = yield* findExistingFile(cwd, [resolved]); - if (existing) { - return existing; - } - } - - for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = path.join(cwd, sourceFile); - const source = yield* fileSystem - .readFileString(sourcePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { - continue; - } - const href = extractIconHref(source); - if (!href) { - continue; - } - const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, 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 051a7d20de00..000000000000 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ /dev/null @@ -1,165 +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 { 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 makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => - Layer.succeed(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"), - }); - -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(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(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", - 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", - }); - }); -}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts deleted file mode 100644 index 61cd043b43b0..000000000000 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, 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; - const env = projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: input.worktreePath, - }); - - yield* terminalManager.open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env, - }); - 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 000000000000..0b017b22e4e7 --- /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 000000000000..e644df06ae64 --- /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 000000000000..fdf95df0b996 --- /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 000000000000..41bf0fabf489 --- /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 1c985cd85922..a997459e63d7 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 4fdaa71de229..50608e7704c7 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,99 +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; + }, +); - 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 ad1b466e2c7f..000000000000 --- 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 17168eda7f1b..000000000000 --- 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 ef0b128c6f79..000000000000 --- 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/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 76055f8b8be7..b46a4ce1ba3c 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,14 @@ +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` + +## T3 Code collaborative browser + +You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. + +For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. + +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -118,6 +129,7 @@ plan content should be human and agent digestible. The final plan must be plan-o Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f8130..f2b04b3a2820 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/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 9a4d1ce9cdf8..65c74f9764a5 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( config: Pick, - baseEnv: NodeJS.ProcessEnv = process.env, + baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { + const resolvedBaseEnv = baseEnv ?? process.env; const homePath = config.homePath.trim(); - if (homePath.length === 0) return baseEnv; + if (homePath.length === 0) return resolvedBaseEnv; const resolvedHomePath = yield* resolveClaudeHomePath(config); return { - ...baseEnv, + ...resolvedBaseEnv, HOME: resolvedHomePath, }; }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f9..ffcc94ca77dc 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 12e98293b12e..ec78b1665ef5 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 5a7132224ef2..d2d09e9d8440 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 ba532864c45a..c394a7d1b439 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 ab01439ffd39..d855d1a45151 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 e7216f833667..6342d1765904 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 916c9d077dd1..191bf8e27db9 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 38b77c692621..97a93f85829c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -248,21 +249,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)) { @@ -296,27 +284,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() @@ -1003,7 +981,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, }), ), @@ -1241,7 +1219,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, }); } @@ -2909,18 +2887,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, + }), ), ), ), @@ -2937,15 +2924,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) { @@ -3003,12 +2992,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, + }), ), ); @@ -3445,6 +3439,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), }; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -3470,6 +3465,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), + ...(mcpSession + ? { + mcpServers: { + "t3-code": { + type: "http", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + } + : {}), }; yield* Effect.annotateCurrentSpan({ @@ -3507,7 +3515,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 d5bbc8f65724..bd5f7ebffc4c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,6 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -30,7 +31,6 @@ import { buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -547,7 +547,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -603,12 +603,15 @@ const probeClaudeCapabilities = ( const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { env: claudeEnvironment, - shell: process.platform === "win32", + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + shell: spawnCommand.shell, }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); @@ -618,12 +621,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( resolveCapabilities?: ( claudeSettings: ClaudeSettings, ) => Effect.Effect, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner | Path.Path > { + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, @@ -648,13 +652,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const versionProbe = yield* runClaudeCommand( + claudeSettings, + ["--version"], + resolvedEnvironment, + ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); 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, @@ -667,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.", }, }); } @@ -692,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, @@ -703,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 04ef44d54e80..515a7c6fcbb0 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, @@ -250,8 +250,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"), @@ -259,7 +259,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", () => @@ -276,7 +276,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(), model: "gpt-5.3-codex", @@ -319,10 +319,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"); }), ); @@ -335,7 +335,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( @@ -350,7 +350,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", @@ -386,7 +386,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( @@ -405,7 +405,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", @@ -442,7 +442,7 @@ function startLifecycleRuntime() { runtimeMode: "full-access", }); const runtime = lifecycleRuntimeFactory.lastRuntime; - assert.ok(runtime); + NodeAssert.ok(runtime); return { adapter, runtime }; }); } @@ -477,17 +477,75 @@ 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"); + }), + ); + + it.effect("labels MCP lifecycle entries with server and tool names", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("mcp_1"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + 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", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }); }), ); @@ -520,16 +578,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"); }), ); @@ -557,16 +615,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"); }), ); @@ -588,16 +646,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"); }), ); @@ -626,16 +684,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"); }), ); @@ -657,16 +715,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)", ); @@ -694,16 +752,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"); }), ); @@ -726,17 +784,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", ); @@ -766,15 +824,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"); }), ); @@ -801,15 +859,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"); }), ); @@ -837,15 +895,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: [], }); }), @@ -876,20 +934,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"); } }), ); @@ -948,17 +1006,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", }); } @@ -1002,16 +1060,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, @@ -1061,15 +1119,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); }), ); }); @@ -1106,20 +1164,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; @@ -1150,7 +1210,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({ @@ -1167,15 +1227,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/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8c9969e2bc44..270126e934ba 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterRequestError, @@ -234,7 +235,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType return "unknown"; } -function itemTitle(itemType: CanonicalItemType): string | undefined { +function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { + if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { + return `${item.server} · ${item.tool}`; + } switch (itemType) { case "assistant_message": return "Assistant message"; @@ -475,7 +479,7 @@ function mapItemLifecycle( payload: { itemType, ...(status ? { status } : {}), - ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), ...(detail ? { detail } : {}), ...(event.payload !== undefined ? { data: event.payload } : {}), }, @@ -1382,6 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -1397,6 +1402,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(serviceTier ? { serviceTier } : {}), + ...(mcpSession + ? { + environment: { + ...(options?.environment ?? process.env), + T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), + }, + appServerArgs: [ + "-c", + `mcp_servers.t3-code.url=${mcpSession.endpoint}`, + "-c", + 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', + ], + } + : {}), }; const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232c..811c362f1e08 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 { 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"; @@ -24,6 +25,7 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -33,6 +35,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -250,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; @@ -292,17 +296,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const clientContext = yield* Layer.build( - CodexClient.layerCommand({ - command: input.binaryPath, - args: ["app-server"], - cwd: input.cwd, - env: { - ...(input.environment ?? process.env), - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }, - }), - ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); @@ -449,12 +471,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner > { + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -480,7 +503,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, cwd: process.cwd(), customModels: codexSettings.customModels, - environment, + environment: resolvedEnvironment, }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d2e51139b9f2..8aeacd870cc4 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"; @@ -13,11 +14,29 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, } 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"] { @@ -42,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({ @@ -54,7 +99,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", sandboxPolicy: { @@ -96,7 +141,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", sandboxPolicy: { @@ -133,7 +178,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", sandboxPolicy: { @@ -149,9 +194,34 @@ describe("buildTurnStartParams", () => { }); }); +describe("T3 browser developer instructions", () => { + it("prefers the product-native preview tools in both collaboration modes", () => { + for (const instructions of [ + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + ]) { + 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", () => { + 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, + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -163,7 +233,7 @@ describe("isRecoverableThreadResumeError", () => { }); it("ignores non-recoverable resume errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -175,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, @@ -184,7 +254,7 @@ describe("isRecoverableThreadResumeError", () => { ), false, ); - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -197,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", @@ -227,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 f9b9c6ab4fba..99ac498f0c36 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -16,6 +16,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -25,10 +26,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"; @@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "does not exist", ]; +export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -84,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; @@ -103,6 +106,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly appServerArgs?: ReadonlyArray; } export interface CodexSessionRuntimeSendTurnInput { @@ -384,7 +388,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" }, + ), + ), ); } @@ -462,7 +472,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))), ), ); @@ -652,16 +662,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); } @@ -715,16 +715,23 @@ export const makeCodexSessionRuntime = ( // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; const env = { - ...(options.environment ?? process.env), + ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const extendEnv = options.environment === undefined; + const spawnCommand = yield* resolveSpawnCommand( + options.binaryPath, + ["app-server", ...(options.appServerArgs ?? [])], + { env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server"], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: options.cwd, env, + extendEnv, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ) .pipe( @@ -747,15 +754,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 = { @@ -775,7 +783,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, @@ -943,7 +951,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(); @@ -999,7 +1007,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(); @@ -1055,7 +1065,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(); @@ -1255,6 +1265,15 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); @@ -1271,7 +1290,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); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c71c6964459f..9795e5a0680c 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 cdb3c224b97e..9760b2f81fb6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -36,12 +36,13 @@ 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"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -49,7 +50,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, @@ -125,7 +126,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; @@ -245,7 +246,7 @@ function resolveRequestedModeId(input: { } function applyRequestedSessionConfiguration(input: { - readonly runtime: AcpSessionRuntimeShape; + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode | undefined; readonly modelSelection: @@ -530,6 +531,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -537,6 +539,23 @@ export function makeCursorAdapter( cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea31..78f62ac21230 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 facdb5a5ff17..cd9b93a47347 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,12 +21,14 @@ 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, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -42,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( @@ -60,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, @@ -394,7 +403,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -407,7 +416,7 @@ const makeCursorAcpProbeRuntime = ( "acp", ], cwd: process.cwd(), - env: environment, + ...(environment ? { env: environment } : {}), }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -415,13 +424,15 @@ 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, - environment: NodeJS.ProcessEnv = process.env, + useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, + environment?: NodeJS.ProcessEnv, ) => makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( Effect.flatMap(useRuntime), @@ -542,7 +553,7 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -558,7 +569,7 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( @@ -604,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; @@ -742,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); }); @@ -927,13 +946,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult const runCursorCommand = ( cursorSettings: CursorSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - env: environment, - shell: process.platform === "win32", + const spawnCommand = yield* resolveSpawnCommand( + cursorSettings.binaryPath, + args, + environment ? { env: environment } : {}, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, }); const child = yield* spawner.spawn(command); @@ -949,10 +973,7 @@ const runCursorCommand = ( return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorAboutCommand = ( - cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, -) => +const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) => Effect.gen(function* () { const jsonResult = yield* runCursorCommand( cursorSettings, @@ -967,7 +988,7 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1000,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, @@ -1011,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.", }, }); } @@ -1068,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) { @@ -1103,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; @@ -1114,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 0b1f99d3c117..71ac7831ed4b 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 04377ad520c0..8c20a4c19360 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 bfd5ae25755d..c871e3c2fc4a 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 0f1007f261b9..40f425cbaa1f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -27,12 +27,13 @@ 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"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -40,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, @@ -100,7 +101,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; @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte cwd, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Scope.Scope, sessionScope), diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565ed..000243869c9e 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 bead8b1a4077..cf5d5ad9c8d8 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"; @@ -15,10 +15,10 @@ import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -149,16 +149,20 @@ const discoverGrokModelsViaAcp = ( const runGrokVersionCommand = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -) => { - const command = grokSettings.binaryPath || "grok"; - return spawnAndCollect( - command, - ChildProcess.make(command, ["--version"], { +) => + Effect.gen(function* () { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment, - shell: process.platform === "win32", - }), - ); -}; + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, @@ -190,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, @@ -202,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.", }, }); } @@ -226,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, @@ -237,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.", }, }); } @@ -249,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, @@ -261,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.", }, }); } @@ -306,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 3f483d8fd7e1..d0475e252844 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/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 54444ce586db..1eb6e47bc19f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter( directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + if (mcpSession && !server.external) { + yield* runOpenCodeSdk("mcp.add", () => + client.mcp.add({ + name: "t3-code", + config: { + type: "remote", + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + oauth: false, + }, + }), + ); + } const openCodeSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ title: `T3 Code ${input.threadId}`, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index eac9f0b43fbb..b0e785512dc4 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/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 8842b1da5cec..a8285e960fc1 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = ( export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const openCodeRuntime = yield* OpenCodeRuntime; + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu .runOpenCodeCommand({ binaryPath: openCodeSettings.binaryPath, args: ["--version"], - environment, + environment: resolvedEnvironment, }) .pipe( Effect.mapError( @@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, serverUrl: openCodeSettings.serverUrl, - environment, + environment: resolvedEnvironment, }); return yield* openCodeRuntime.loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 7fb545b2bed1..c4145ecf1a0e 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 4e43e04cb7c3..0fd88b4262a6 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 f2c5892a2c62..dbfa7faffeab 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 56b80f6c4a2b..b3ab11454956 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); @@ -1039,7 +1051,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // This test intentionally avoids `mockCommandSpawnerLayer` so the real // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. // We point `binaryPath` at a name that cannot exist on any machine so // the real `ChildProcessSpawner` deterministically returns ENOENT; the // probe wraps that as `CodexAppServerSpawnError` and @@ -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; @@ -1159,6 +1178,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { @@ -1176,19 +1196,28 @@ 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), - // `it.live` does not inherit layers from the outer `it.layer` - // wrapper, so provide `NodeServices.layer` inline. This is the - // same real `ChildProcessSpawner` + `FileSystem` + `Path` - // services that production uses. + 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); + return spawner.spawn(command); + }), + ), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1196,13 +1225,10 @@ 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"`. What *distinguishes* - // the two probe runs is `checkedAt` — each probe stamps a - // fresh DateTime, so we capture it and assert it advances - // after the settings mutation. + // snapshot should be `status: "error"`. let initialProviders = yield* registry.getProviders; for ( let attempts = 0; @@ -1220,13 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - const initialCheckedAt = initialCodex?.checkedAt; - assert.notStrictEqual(initialCheckedAt, undefined); - - // The rebuilt instance may re-probe synchronously during the - // settings update. Advance the TestClock first so `checkedAt` - // can safely act as the fresh-probe marker this assertion uses. - yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1242,8 +1262,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, }); - // Poll with TestClock until `checkedAt` advances or we hit a - // generous virtual 3-second ceiling. + // Poll until the injected process boundary observes the new + // executable. This verifies the public settings-to-probe behavior + // without depending on timestamps assigned by TestClock. const refreshed = yield* Effect.gen(function* () { for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; @@ -1251,7 +1272,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if ( codex !== undefined && codex.status === "error" && - codex.checkedAt !== initialCheckedAt + spawnedCommands.includes(secondMissing) ) { return providers; } @@ -1262,11 +1283,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.notStrictEqual( - reprobedCodex?.checkedAt, - initialCheckedAt, - "Expected a fresh probe after settings change, got the stale snapshot", - ); + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1300,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( @@ -1316,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"); @@ -1354,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") { @@ -1389,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"), @@ -1844,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) => { @@ -1859,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 6a72bf699414..ccbbce1759f0 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 2bce1f483b74..2eaaeb8ce3c0 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -24,7 +24,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"; @@ -47,15 +47,14 @@ 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); /** @@ -67,6 +66,9 @@ export interface ProviderServiceLiveOptions { readonly canonicalEventLogger?: EventNdjsonLogger; } +type ProviderServiceMethod = + ProviderService.ProviderService["Service"][Name]; + const ProviderRollbackConversationInput = Schema.Struct({ threadId: ThreadId, numTurns: NonNegativeInt, @@ -139,7 +141,7 @@ function toRuntimePayloadFromSession( } function readPersistedModelSelection( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], ): ModelSelection | undefined { if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { return undefined; @@ -149,7 +151,7 @@ function readPersistedModelSelection( } function readPersistedCwd( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], ): string | undefined { if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { return undefined; @@ -200,18 +202,30 @@ 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) => + McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + Effect.tap((credential) => + credential + ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) + : Effect.void, + ), + ); + const clearMcpSession = (threadId: ThreadId) => + McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( + Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), + ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( @@ -339,7 +353,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); @@ -383,16 +397,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const resumed = yield* adapter.startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - providerInstanceId: bindingInstanceId, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }); + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const resumed = yield* adapter + .startSession({ + threadId: input.binding.threadId, + provider: input.binding.provider, + providerInstanceId: bindingInstanceId, + ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), + ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", + }) + .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); if (resumed.provider !== adapter.provider) { + yield* clearMcpSession(input.binding.threadId); return yield* toValidationError( input.operation, `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, @@ -501,7 +519,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", @@ -572,14 +590,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); - const session = yield* adapter.startSession({ - ...input, - providerInstanceId: resolvedInstanceId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }); + yield* prepareMcpSession(threadId, resolvedInstanceId); + const session = yield* adapter + .startSession({ + ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }) + .pipe(Effect.onError(() => clearMcpSession(threadId))); if (session.provider !== adapter.provider) { + yield* clearMcpSession(threadId); return yield* toValidationError( "ProviderService.startSession", `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, @@ -620,7 +642,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, @@ -695,7 +717,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", @@ -732,7 +754,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", @@ -770,7 +792,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({ @@ -804,7 +826,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", @@ -827,6 +849,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } + yield* clearMcpSession(input.threadId); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -851,7 +874,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]) => @@ -872,13 +895,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) { @@ -929,13 +961,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({ @@ -998,6 +1030,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); + McpProviderSession.clearAllMcpProviderSessions(); const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { @@ -1027,7 +1061,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), + }), ), ), ); @@ -1046,14 +1082,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 f9793ca9d1fe..079b7f10ebfd 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 0508f6c8cb34..23075bd9a06e 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 18e6166c1cdd..e976c183a438 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"; @@ -118,7 +117,7 @@ function makeReadModel( describe("ProviderSessionReaper", () => { let runtime: ManagedRuntime.ManagedRuntime< - ProviderSessionReaper | ProviderSessionRuntimeRepository, + ProviderSessionReaper | ProviderSessionRuntime.ProviderSessionRuntimeRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -176,7 +175,7 @@ describe("ProviderSessionReaper", () => { streamEvents: Stream.empty, }; - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( @@ -238,7 +237,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({ @@ -286,7 +287,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({ @@ -333,7 +336,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({ @@ -380,7 +385,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({ @@ -449,7 +456,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({ @@ -530,7 +539,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 3a57f374de42..c738882c23a4 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 f2e286c589c6..5533a04bc830 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 000000000000..8c92d523aee6 --- /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 6146980e4fb5..06bff3aa6113 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 4ed64890fc3e..4fc2c443e116 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,13 +7,14 @@ 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"; import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, @@ -47,6 +49,7 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -73,50 +76,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 {} @@ -138,24 +244,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 > => @@ -200,12 +292,17 @@ const makeAcpSessionRuntime = ( ), ); + const spawnCommand = yield* resolveSpawnCommand( + options.spawn.command, + options.spawn.args, + options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), - shell: process.platform === "win32", + ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + shell: spawnCommand.shell, }), ) .pipe( @@ -400,7 +497,7 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -413,7 +510,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -426,7 +523,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -566,9 +663,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 07b68d9815a8..eebe5ddd92ec 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 5893c33215d6..169d7c6206d3 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({ @@ -66,11 +65,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 642548832fa0..ee8af1e52668 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 88547fb3afa6..bbf301fa4077 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 9c48e4410327..a83c134d5bd2 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 { @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -204,7 +206,7 @@ export function toOpenCodeFileParts(input: { type: "file", mime: attachment.mimeType, filename: attachment.name, - url: pathToFileURL(attachmentPath).href, + url: NodeURL.pathToFileURL(attachmentPath).href, }); } @@ -276,13 +278,17 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { + const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, [...input.args], { - shell: process.platform === "win32", - env: input.environment ?? process.env, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + shell: spawnCommand.shell, + ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { { concurrency: "unbounded" }, ); const exitCode = Number(code); - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: `spawn ${input.binaryPath} ENOENT`, @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, args, { - detached: process.platform !== "win32", - shell: process.platform === "win32", + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, env: { - ...(input.environment ?? process.env), + ...input.environment, OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, }, + extendEnv: input.environment === undefined, }), ) .pipe( @@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - process.platform === "win32" + hostPlatform === "win32" ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) : Effect.sync(() => { try { diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 73428f0a4456..8937844f6136 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,19 +1,23 @@ // @effect-diagnostics nodeBuiltinImport:off -import { afterEach, expect, it } from "@effect/vitest"; -import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { expect, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import os from "node:os"; -import path from "node:path"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +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 { - clearLatestProviderVersionCacheForTests, createProviderVersionAdvisory, + enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, normalizeCommandPath, + ProviderVersionCache, + resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -21,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(os.tmpdir(), `${name}-${id}`)), + Effect.map((id) => NodePath.join(NodeOS.tmpdir(), `${name}-${id}`)), ); const isNativeTestCommandPath = (expectedPathSegment: string) => @@ -64,12 +68,73 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); - -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); +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", () => + resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + [ + "@example/package-tool", + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "9.9.9", + }, + ], + ]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("cached provider version should not make an HTTP request"), + ), + ), + Effect.map((version) => { + expect(version).toBe("9.9.9"); + }), + ), + ); + + 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({ @@ -138,21 +203,23 @@ 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); - - expect( - packageToolUpdate.resolve({ + 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, + { binaryPath: "package-tool", - platform: "darwin", env: { PATH: vitePlusBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", update: { @@ -173,20 +240,22 @@ 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"); - expect( - nativePackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: "native-package-tool", - platform: "win32", env: { PATH: bunBinDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -207,21 +276,23 @@ 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); - - expect( - scopedPackageToolUpdate.resolve({ + 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, + { binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: pnpmHomeDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -241,7 +312,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/package-tool", - platform: "darwin", env: { PATH: "", }, @@ -266,21 +336,23 @@ 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); - - expect( - nativePackageToolUpdate.resolve({ + 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, + { binaryPath: "native-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -301,21 +373,23 @@ 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); - - expect( - scopedPackageToolUpdate.resolve({ + 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, + { binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -335,7 +409,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/native-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -359,7 +432,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/scoped-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -382,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", @@ -391,17 +463,16 @@ 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, - platform: "darwin", env: { PATH: "", }, @@ -426,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", @@ -439,17 +510,16 @@ 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, - platform: "darwin", env: { PATH: "", }, @@ -475,7 +545,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", - platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD", diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 3b0fabf6a99b..8645f9f943c9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; +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 FileSystem from "effect/FileSystem"; @@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction { export interface ProviderMaintenanceCapabilityResolutionOptions { readonly binaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; - readonly platform?: NodeJS.Platform; + readonly resolvedCommandPath?: string | null; readonly realCommandPath?: string | null; } @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { } | null; } -interface LatestVersionCacheEntry { +export interface ProviderVersionCacheEntry { readonly expiresAt: number; readonly version: string | null; } -const latestVersionCache = new Map(); +export const ProviderVersionCache = Context.Reference>( + "@t3tools/server/providerMaintenance/ProviderVersionCache", + { + defaultValue: () => new Map(), + }, +); const NpmLatestVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); -export function clearLatestProviderVersionCacheForTests(): void { - latestVersionCache.clear(); -} - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( return resolver.resolve(options); } + const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + (yield* resolveCommandPath(binaryPath, { env }).pipe( + Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); } @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ ...options, + env, + resolvedCommandPath, realCommandPath, }); }); @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } + const latestVersionCache = yield* ProviderVersionCache; const cached = latestVersionCache.get(packageName); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { @@ -446,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/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5f5f975a4e3a..5ffb69cd5f74 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { - clearLatestProviderVersionCacheForTests, makeProviderMaintenanceCapabilities, + ProviderVersionCache, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { if (provider === CURSOR_DRIVER) { return makeProviderMaintenanceCapabilities({ @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ProviderVersionCache, new Map()), + ), + ), ), ), ); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index fdc8b4c4a71c..abe138fdfb94 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 ce43c5e6eab7..dfe31ffdc442 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) => @@ -75,8 +86,13 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman ); const result: CommandResult = { stdout, stderr, code: exitCode }; - if (isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { + 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 64cb9ccd4177..07f67cd7de8f 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 0b9b365f360a..2fe0424b4f57 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 000000000000..308d84a14467 --- /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/push/WebPushNotifier.ts b/apps/server/src/push/WebPushNotifier.ts deleted file mode 100644 index a781b657e867..000000000000 --- a/apps/server/src/push/WebPushNotifier.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * WebPushNotifier — sends Web Push notifications to subscribed browsers when an - * agent thread finishes or needs attention. - * - * VAPID keys and subscriptions are persisted in the ServerSecretStore. The - * notifier watches the orchestration event stream, computes per-thread agent - * awareness (the same logic the cloud relay uses), and pushes on transitions - * into a terminal (completed/failed) or interruptive (needs approval/input) - * phase. Delivery is best-effort; gone subscriptions are pruned automatically. - * - * @module WebPushNotifier - */ -import webpush from "web-push"; -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 type * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; - -import type { - PushStatusResult, - PushSubscribeResult, - PushUnsubscribeInput, - PushUnsubscribeResult, - ThreadId, -} from "@t3tools/contracts"; -import { PushError, PushSubscriptionInput } from "@t3tools/contracts"; -import { - type AgentAwarenessPhase, - isInterruptiveAgentAwarenessPhase, - isTerminalAgentAwarenessPhase, - projectThreadAwareness, -} from "@t3tools/shared/agentAwareness"; - -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; -import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { eventThreadId, shouldPublishAgentAwarenessEvent } from "../relay/AgentAwarenessRelay.ts"; - -const VAPID_SECRET = "t3/webPush/vapidKeys"; -const SUBSCRIPTIONS_SECRET = "t3/webPush/subscriptions"; -const VAPID_SUBJECT = "mailto:web-push@t3code.local"; - -const VapidKeys = Schema.Struct({ publicKey: Schema.String, privateKey: Schema.String }); -type VapidKeys = typeof VapidKeys.Type; -const VapidKeysJson = Schema.fromJsonString(VapidKeys); -const decodeVapidKeys = Schema.decodeUnknownEffect(VapidKeysJson); -const encodeVapidKeys = Schema.encodeEffect(VapidKeysJson); - -const SubscriptionList = Schema.Array(PushSubscriptionInput); -const SubscriptionListJson = Schema.fromJsonString(SubscriptionList); -const decodeSubscriptionList = Schema.decodeUnknownEffect(SubscriptionListJson); -const encodeSubscriptionList = Schema.encodeEffect(SubscriptionListJson); - -const PushPayload = Schema.Struct({ - title: Schema.String, - body: Schema.String, - url: Schema.String, - tag: Schema.String, -}); -type PushPayload = typeof PushPayload.Type; -const encodePushPayload = Schema.encodeEffect(Schema.fromJsonString(PushPayload)); - -export interface WebPushNotifierShape { - readonly getStatus: () => Effect.Effect; - readonly subscribe: ( - input: PushSubscriptionInput, - ) => Effect.Effect; - readonly unsubscribe: (input: PushUnsubscribeInput) => Effect.Effect; - readonly start: () => Effect.Effect; -} - -export class WebPushNotifier extends Context.Service()( - "t3/push/WebPushNotifier", -) {} - -const decoder = new TextDecoder(); -const encoder = new TextEncoder(); - -function notifiablePhase(phase: AgentAwarenessPhase): boolean { - return isTerminalAgentAwarenessPhase(phase) || isInterruptiveAgentAwarenessPhase(phase); -} - -function webNotificationUrl(environmentId: string, threadId: string): string { - return `/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; -} - -function isGoneSubscriptionError(error: unknown): boolean { - const statusCode = (error as { statusCode?: number }).statusCode; - return statusCode === 404 || statusCode === 410; -} - -const make = Effect.gen(function* () { - const secrets = yield* ServerSecretStore.ServerSecretStore; - const snapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const serverEnvironment = yield* ServerEnvironment; - - // Load or generate the VAPID keypair, then register it with web-push. - const vapidKeys = yield* Effect.gen(function* () { - const encoded = yield* secrets.get(VAPID_SECRET).pipe(Effect.orElseSucceed(() => null)); - if (encoded !== null) { - const parsed = yield* decodeVapidKeys(decoder.decode(encoded)).pipe( - Effect.orElseSucceed(() => null), - ); - if (parsed !== null) { - return parsed; - } - } - const generated = yield* Effect.sync(() => webpush.generateVAPIDKeys()); - const keys: VapidKeys = { publicKey: generated.publicKey, privateKey: generated.privateKey }; - const serialized = yield* encodeVapidKeys(keys).pipe(Effect.orElseSucceed(() => null)); - if (serialized !== null) { - yield* secrets - .set(VAPID_SECRET, encoder.encode(serialized)) - .pipe(Effect.orElseSucceed(() => undefined)); - } - return keys; - }); - yield* Effect.sync(() => { - try { - webpush.setVapidDetails(VAPID_SUBJECT, vapidKeys.publicKey, vapidKeys.privateKey); - } catch { - // Invalid keys would only surface on send; ignore here. - } - }); - - // Subscriptions: in-memory map keyed by endpoint, mirrored to the secret store. - const loadedSubscriptions = yield* secrets.get(SUBSCRIPTIONS_SECRET).pipe( - Effect.flatMap((bytes) => - bytes === null - ? Effect.succeed>([]) - : decodeSubscriptionList(decoder.decode(bytes)), - ), - Effect.orElseSucceed(() => [] as ReadonlyArray), - ); - const subscriptionsRef = yield* Ref.make( - new Map( - loadedSubscriptions.map((subscription) => [subscription.endpoint, subscription]), - ), - ); - const lastPhaseByThreadRef = yield* Ref.make(new Map()); - - const persistSubscriptions = Effect.gen(function* () { - const subscriptions = [...(yield* Ref.get(subscriptionsRef)).values()]; - const serialized = yield* encodeSubscriptionList(subscriptions); - yield* secrets.set(SUBSCRIPTIONS_SECRET, encoder.encode(serialized)); - }); - - const getStatus: WebPushNotifierShape["getStatus"] = () => - Effect.succeed({ enabled: true, vapidPublicKey: vapidKeys.publicKey }); - - const subscribe: WebPushNotifierShape["subscribe"] = (input) => - Effect.gen(function* () { - yield* Ref.update(subscriptionsRef, (subscriptions) => - new Map(subscriptions).set(input.endpoint, input), - ); - yield* persistSubscriptions; - return { ok: true }; - }).pipe( - Effect.mapError( - (cause) => new PushError({ message: "Failed to store push subscription.", cause }), - ), - ); - - const unsubscribe: WebPushNotifierShape["unsubscribe"] = (input) => - Effect.gen(function* () { - yield* Ref.update(subscriptionsRef, (subscriptions) => { - const next = new Map(subscriptions); - next.delete(input.endpoint); - return next; - }); - yield* persistSubscriptions; - return { ok: true } as const; - }).pipe(Effect.orElseSucceed(() => ({ ok: true }))); - - const removeEndpoints = (endpoints: ReadonlyArray) => - Effect.gen(function* () { - if (endpoints.length === 0) return; - yield* Ref.update(subscriptionsRef, (subscriptions) => { - const next = new Map(subscriptions); - for (const endpoint of endpoints) { - next.delete(endpoint); - } - return next; - }); - yield* persistSubscriptions.pipe(Effect.orElseSucceed(() => undefined)); - }); - - const sendToAll = (payload: PushPayload) => - Effect.gen(function* () { - const subscriptions = [...(yield* Ref.get(subscriptionsRef)).values()]; - if (subscriptions.length === 0) { - return; - } - const serialized = yield* encodePushPayload(payload).pipe(Effect.orElseSucceed(() => null)); - if (serialized === null) { - return; - } - const goneEndpoints = yield* Effect.forEach( - subscriptions, - (subscription) => - Effect.promise(() => - webpush - .sendNotification( - { - endpoint: subscription.endpoint, - keys: { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth }, - }, - serialized, - ) - .then(() => null) - .catch((error: unknown) => - isGoneSubscriptionError(error) ? subscription.endpoint : null, - ), - ), - { concurrency: 4 }, - ); - yield* removeEndpoints( - goneEndpoints.filter((endpoint): endpoint is string => endpoint !== null), - ); - }); - - const notifyForThread = (threadId: ThreadId) => - Effect.gen(function* () { - const environmentId = yield* serverEnvironment.getEnvironmentId; - const thread = yield* snapshotQuery.getThreadShellById(threadId); - if (Option.isNone(thread)) { - return; - } - const project = yield* snapshotQuery.getProjectShellById(thread.value.projectId); - if (Option.isNone(project)) { - return; - } - const state = projectThreadAwareness({ - environmentId, - project: project.value, - thread: thread.value, - }); - if (state === null) { - return; - } - - const lastPhaseByThread = yield* Ref.get(lastPhaseByThreadRef); - const previousPhase = lastPhaseByThread.get(threadId); - yield* Ref.update(lastPhaseByThreadRef, (phases) => - new Map(phases).set(threadId, state.phase), - ); - - if (!notifiablePhase(state.phase) || state.phase === previousPhase) { - return; - } - - yield* sendToAll({ - title: state.headline, - body: state.detail ? `${state.threadTitle} — ${state.detail}` : state.threadTitle, - url: webNotificationUrl(environmentId, threadId), - tag: threadId, - }); - }).pipe(Effect.catchCause(() => Effect.void)); - - const start: WebPushNotifierShape["start"] = () => - Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - const threadId = eventThreadId(event); - if (threadId === null || !shouldPublishAgentAwarenessEvent(event)) { - return Effect.void; - } - return notifyForThread(threadId); - }), - ).pipe(Effect.asVoid); - - return { getStatus, subscribe, unsubscribe, start } satisfies WebPushNotifierShape; -}); - -export const layer = Layer.effect(WebPushNotifier, make); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bbfbd236ad00..40ed694723d7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -17,6 +17,7 @@ import type { RelayAgentActivityState, } from "@t3tools/contracts/relay"; import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -25,9 +26,10 @@ 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 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, @@ -62,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); @@ -82,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)), @@ -95,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"; @@ -470,7 +494,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), }), @@ -522,6 +546,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); const fetchSeen = yield* Deferred.make(); + const userSpans: Array = []; + const productSpans: Array = []; + const collectingTracer = (spans: Array) => + 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; + }, + }); const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; @@ -588,7 +626,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; @@ -600,7 +642,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), }), @@ -648,6 +690,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); + expect(productSpans).toContain("makePublishProof"); + expect(userSpans).not.toContain("makePublishProof"); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -655,6 +699,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.provideMerge(NodeServices.layer), ), ), + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), ); }), ), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 960f27e752bb..4e036e3ea0e9 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,12 +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"; @@ -27,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 { @@ -99,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."; @@ -254,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([ @@ -303,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; @@ -400,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", { @@ -409,6 +424,7 @@ const make = Effect.gen(function* () { }); }), Effect.withSpan("AgentAwarenessRelay.publishThread"), + withRelayClientTracing, ); const publishActiveThreadsUnsafe = Effect.gen(function* () { @@ -421,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; @@ -442,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) => { @@ -494,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 eb8758b12829..839eb73b2bb7 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 63f1d1332135..db1dc5bc8d2f 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 2183d62f22e2..e1daf20ed570 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,12 +15,13 @@ import { GitCommandError, KeybindingRule, MessageId, - ExternalLauncherError, + ExternalLauncherCommandNotFoundError, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, + type PreviewEvent, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -48,7 +50,9 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, @@ -66,59 +70,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 { resolveAttachmentRelativePath } from "./attachmentPaths.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 { - 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 { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WebPushNotifier } from "./push/WebPushNotifier.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.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 * 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 * 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"; @@ -129,10 +107,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"; @@ -338,32 +313,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* () { @@ -371,8 +354,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, @@ -400,8 +383,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, @@ -499,21 +482,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 = WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), + const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( + 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, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -542,7 +525,7 @@ const buildAppUnderTest = (options?: { disableLogger: true, }).pipe( Layer.provide( - Layer.mock(Keybindings)({ + Layer.mock(Keybindings.Keybindings)({ loadConfigState: Effect.succeed({ keybindings: [], issues: [], @@ -552,7 +535,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry)({ + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), @@ -566,7 +549,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), @@ -577,6 +560,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), ...options?.layers?.externalLauncher, }), ), @@ -654,18 +638,41 @@ 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, }), ), Layer.provide( - Layer.mock(OrchestrationEngineService)({ + Layer.mergeAll( + Layer.mock(PreviewManager.PreviewManager)({ + open: () => Effect.die("PreviewManager not stubbed in this test"), + navigate: () => Effect.die("PreviewManager not stubbed in this test"), + reportStatus: () => Effect.void, + refresh: () => Effect.void, + close: () => Effect.void, + list: () => Effect.succeed({ sessions: [] }), + events: Stream.empty, + subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }), + Layer.mock(PortScanner.PortDiscovery)({ + scan: () => Effect.succeed([]), + subscribe: () => Effect.void, + retain: Effect.void, + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), + ), + ), + Layer.provide( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, @@ -673,15 +680,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(WebPushNotifier)({ - getStatus: () => Effect.succeed({ enabled: false }), - subscribe: () => Effect.succeed({ ok: true }), - unsubscribe: () => Effect.succeed({ ok: true }), - start: () => Effect.void, - }), - ), - Layer.provide( - Layer.mock(ProjectionSnapshotQuery)({ + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getShellSnapshot: () => @@ -710,7 +709,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(CheckpointDiffQuery)({ + Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({ getTurnDiff: () => Effect.succeed({ threadId: defaultThreadId, @@ -732,13 +731,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, @@ -746,7 +745,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerRuntimeStartup)({ + Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ awaitCommandReady: Effect.void, markHttpListening: Effect.void, enqueueCommand: (effect) => effect, @@ -754,22 +753,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, }), @@ -1261,61 +1260,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves project favicon requests before the dev URL redirect", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-", - }); - yield* fileSystem.writeFileString( - path.join(projectDir, "favicon.svg"), - "router-project-favicon", - ); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.equal(yield* response.text, "router-project-favicon"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves the fallback project favicon when no icon exists", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-fallback-", - }); - - yield* buildAppUnderTest({ - config: { devUrl: new URL("http://127.0.0.1:5173") }, - }); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - - assert.equal(response.status, 200); - assert.include(yield* response.text, 'data-fallback="project-favicon"'); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("serves the public environment descriptor without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3177,28 +3121,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const faviconBody = (yield* faviconResponse.json) as { - readonly _tag: string; - readonly code: string; - readonly requiredScope: string; - readonly traceId: string; - }; - assert.equal(overbroadPairingResponse.status, 403); assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); assert.equal(pairingResponse.status, 200); assert.equal(wsTicketResponse.status, 200); - assert.equal(faviconResponse.status, 403); - assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); - assert.equal(faviconBody.code, "insufficient_scope"); - assert.equal(faviconBody.requiredScope, "orchestration:read"); - assert.equal(typeof faviconBody.traceId, "string"); - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; const rpcError = yield* Effect.flip( Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), @@ -3742,29 +3668,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "does not accept session tokens via query parameters on authenticated HTTP routes", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const projectDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-router-project-favicon-query-token-", - }); - - yield* buildAppUnderTest(); - - const { cookie } = yield* bootstrapBrowserSession(); - assert.isDefined(cookie); - const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); - - const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, - ); - - assert.equal(response.status, 401); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3835,60 +3738,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves attachment files from state dir", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-11111111-1111-4111-8111-111111111111"; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: `${attachmentId}.bin`, - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - - const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("serves attachment files for URL-encoded paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const config = yield* buildAppUnderTest(); - const attachmentPath = resolveAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - relativePath: "thread%20folder/message%20folder/file%20name.png", - }); - assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); - yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); - - const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 200); - assert.equal(yield* response.text, "attachment-encoded-ok"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("proxies browser OTLP trace exports through the server", () => Effect.gen(function* () { const upstreamRequests: Array<{ @@ -4178,22 +4027,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("returns 404 for missing attachment id lookups", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", - { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - }, - ); - assert.equal(response.status, 404); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { @@ -4502,7 +4335,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isAtLeast(response.entries.length, 1); assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-files-" }); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "index.ts"), + "export const answer = 42;\n", + ); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all({ + listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }), + file: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "src/index.ts", + }), + }), + ), + ); + + assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts")); + assert.deepEqual(response.file, { + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => @@ -4559,30 +4428,150 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.entries.length, 0); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).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)), ); @@ -4663,12 +4652,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)), ); @@ -4702,8 +4698,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: { @@ -6029,6 +6026,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, @@ -6047,17 +6045,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", @@ -6070,6 +6092,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + fetchRemote, + resolveRemoteTrackingCommit, createWorktree, }, vcsStatusBroadcaster: { @@ -6121,6 +6145,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, runSetupScript: true, }, @@ -6142,10 +6167,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, @@ -6174,7 +6214,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", @@ -6183,8 +6223,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({ @@ -6268,7 +6319,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", @@ -6277,7 +6328,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", @@ -6387,7 +6442,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 85d340c5dc56..81d0013b20ca 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,11 +4,10 @@ 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 { - attachmentsRouteLayer, otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, + assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -17,27 +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"; @@ -45,15 +49,14 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; -import * as WebPushNotifier from "./push/WebPushNotifier.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 { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.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 * 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"; @@ -64,13 +67,14 @@ 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 * 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"; import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; @@ -97,32 +101,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 { @@ -131,7 +135,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, }); @@ -158,12 +162,11 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), - Layer.provideMerge(WebPushNotifier.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter @@ -192,7 +195,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), @@ -229,28 +232,39 @@ 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 TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provideMerge(VcsDriverRegistryLayerLive), +const TerminalLayerLive = TerminalManager.layer.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), +); + +const PreviewLayerLive = Layer.empty.pipe( + Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortScannerLayerLive), ); -const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), +const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), Layer.provide(WorkspaceEntriesLayerLive), ); const WorkspaceLayerLive = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, ); +const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), Layer.provide(ServerSecretStore.layer), @@ -276,9 +290,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), + 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` @@ -291,18 +305,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(ProjectFaviconResolverLive), - Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(ProjectFaviconResolverLayerLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(ServerEnvironment.layer), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -318,41 +332,43 @@ 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), ); export const makeRoutesLayer = Layer.mergeAll( - HttpApiBuilder.layer(EnvironmentHttpApi).pipe( - Layer.provide(authHttpApiLayer), - Layer.provide(connectHttpApiLayer), - Layer.provide(orchestrationHttpApiLayer), - Layer.provide(serverEnvironmentHttpApiLayer), - Layer.provide(environmentAuthenticatedAuthLayer), + Layer.mergeAll( + HttpApiBuilder.layer(EnvironmentHttpApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(connectHttpApiLayer), + Layer.provide(orchestrationHttpApiLayer), + Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + ), + otlpTracesProxyRouteLayer, + assetRouteLayer, + staticAndDevRouteLayer, + websocketRpcRouteLayer, ), - attachmentsRouteLayer, - otlpTracesProxyRouteLayer, - projectFaviconRouteLayer, - staticAndDevRouteLayer, - websocketRpcRouteLayer, + McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; - fixPath(); + yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { yield* HttpServer.HttpServer; - const startup = yield* ServerRuntimeStartup; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; yield* startup.markHttpListening; }), ); @@ -462,6 +478,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 14fbba9e2389..4f7b75fb4bd3 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 88661b1593a3..855d03490efe 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 21d64b2cdbc7..df9a965a820c 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 Claude default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { + assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { instanceId: ProviderInstanceId.make("claudeAgent"), model: DEFAULT_CLAUDE_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 a1f075e02dbb..17da93c72cbb 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 000000000000..749fd3062e91 --- /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 996f9a2bfc9e..329b000369a0 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 d24f2ee2826b..504d99e18def 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 0e126604b4af..4119a72640fe 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 f3078fcd06c5..1cd4b3885521 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 e39ce9f01003..609efe4df4c9 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 4ba3777159b5..21db25e79912 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 8d8e081cb896..bf2ac9829275 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 e93362b8423e..5a9759ace0b7 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 632778eca244..f7d7f6671a46 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 07a3d386a35f..eeb4c8fbdd2a 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 f3fd502f7fb8..974fbb94a393 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 fb765b352c2a..5df4862b4091 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 d6c858c28bd5..bf3f27378b5e 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 32fd1a91ce37..9e8a68295667 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 41329b97f751..b5d5d3a55f8f 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 c075027151a2..87621e5c8bcf 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 bd430d9d01ab..a2926afd0efb 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 842cf4a17cfe..0d06e0665214 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 77f41600e0fa..2cba12f1b3f7 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 f65710c4c9c9..9e4702af04cd 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 eab46d235607..660f32283e0f 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 000000000000..7e5324882799 --- /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 f0602f03d14d..5f93dbcaa425 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 856d6948e090..e3a6bd1fb205 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 833956ecc7ed..5c4d27e46f94 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 08f794d1f5c8..fb70d677e435 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 811b55c70a35..861da9a10e05 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 106d300ec2da..1b46369e25c4 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/startupAccess.ts b/apps/server/src/startupAccess.ts index 7a03dafaefe1..7df131669bab 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,4 +1,4 @@ -import { networkInterfaces } from "node:os"; +import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { readonly pairingUrl: string; } -type NetworkInterfacesMap = ReturnType; +type NetworkInterfacesMap = ReturnType; export const isLoopbackHost = (host: string | undefined): boolean => { if (!host || host.length === 0) { @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || export const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { if (!host) { return "localhost"; @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { const connectionHost = resolveHeadlessConnectionHost(host, interfaces); return `http://${formatHostForUrl(connectionHost)}:${port}`; 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 5aa47406d9b5..d69bab32febb 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 65% rename from apps/server/src/telemetry/Layers/AnalyticsService.ts rename to apps/server/src/telemetry/AnalyticsService.ts index 27bf64c7be0e..5fdc7bdeb199 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,23 +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; @@ -37,15 +40,41 @@ const TelemetryEnvConfig = Config.all({ maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( Config.withDefault(1_000), ), + 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"; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => @@ -74,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; @@ -87,9 +116,9 @@ const makeAnalyticsService = Effect.gen(function* () { properties: { ...event.properties, $process_person_profile: false, - platform: process.platform, - wsl: process.env.WSL_DISTRO_NAME, - arch: process.arch, + platform: hostPlatform, + wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), + arch: hostArchitecture, t3CodeVersion: packageJson.version, clientType, }, @@ -104,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) { @@ -129,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; @@ -149,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 000000000000..ab151821789a --- /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 da04bd0b2665..b6c3d0066dff 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,11 +1,14 @@ +import * as NodeOS from "node:os"; 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 { homedir } from "node:os"; -import { ServerConfig } from "../config.ts"; + +import * as ServerConfig from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ @@ -17,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; + +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)); -const hash = (value: string) => +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(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(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, + }), ), ); @@ -83,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 a2717c790dcb..879a1de7cdbd 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 000000000000..e04a54e6d333 --- /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 57% rename from apps/server/src/terminal/Layers/BunPTY.ts rename to apps/server/src/terminal/BunPtyAdapter.ts index 5fde1469193c..88b68940de11 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -2,12 +2,37 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -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; @@ -32,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); } @@ -59,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); @@ -75,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; @@ -92,17 +117,15 @@ class BunPtyProcess implements PtyProcess { } } -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - if (process.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, { @@ -118,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 cd490de1e3f3..000000000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,2398 +0,0 @@ -import { - DEFAULT_TERMINAL_ID, - type TerminalAttachInput, - type TerminalAttachStreamEvent, - type TerminalEvent, - type TerminalMetadataStreamEvent, - type TerminalOpenInput, - type TerminalSessionSnapshot, - type TerminalSessionStatus, - type TerminalSummary, -} from "@t3tools/contracts"; -import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; -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 { - 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; -} - -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 = process.platform, - env: NodeJS.ProcessEnv = process.env, -): string { - if (platform === "win32") { - return "pwsh.exe"; - } - return env.SHELL ?? "bash"; -} - -function normalizeShellCommand( - value: string | undefined, - platform: NodeJS.Platform = process.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 = process.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 = process.platform, - env: NodeJS.ProcessEnv = process.env, -): 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 = [ - `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, - "if ($null -eq $c) { exit 1 }", - "Write-Output $c.Name", - "exit 0", - ].join("; "); - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - 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 } as const; - } - const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; - if (name.length === 0) { - return { hasRunningSubprocess: true, childCommand: null } as const; - } - const normalized = normalizeChildCommandName(name, platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - } 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 }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null }; - } - 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 }; - } - - 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; - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - }; -}); - -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null }; - } - 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: PtyAdapterShape; - shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; - subprocessInspector?: TerminalSubprocessInspector; - subprocessPollIntervalMs?: number; - processKillGraceMs?: number; - maxRetainedInactiveSessions?: number; -} - -const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { - const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; - return yield* makeTerminalManagerWithOptions({ - logsDir: terminalLogsDir, - ptyAdapter, - }); -}); - -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 logsDir = options.logsDir; - const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = options.platform ?? process.platform; - 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; - - 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* 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* 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* 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* 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; - 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) { - 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, 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) - ) { - 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* () { - 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: 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/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts deleted file mode 100644 index 15d24360f7e8..000000000000 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Effect from "effect/Effect"; -import { assert, it } from "@effect/vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; - -it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { - it.effect("adds executable bits when helper exists but is not executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o644); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o755); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); -}); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts deleted file mode 100644 index c81d76f5d1ee..000000000000 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createRequire } 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 { PtyAdapter } from "../Services/PTY.ts"; -import { - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -let didEnsureSpawnHelperExecutable = false; - -const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = createRequire(import.meta.url); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), - ]; - - for (const candidate of candidates) { - if (yield* fs.exists(candidate)) { - return candidate; - } - } - return null; -}).pipe(Effect.orElseSucceed(() => null)); - -export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { - const fs = yield* FileSystem.FileSystem; - if (process.platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; - - const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); - if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } - - if (!(yield* fs.exists(helperPath))) { - return; - } - - // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. - yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); -}); - -class NodePtyProcess implements PtyProcess { - private readonly process: import("node-pty").IPty; - - constructor(process: import("node-pty").IPty) { - this.process = process; - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - this.process.write(data); - } - - resize(cols: number, rows: number): void { - this.process.resize(cols, rows); - } - - kill(signal?: string): void { - this.process.kill(signal); - } - - onData(callback: (data: string) => void): () => void { - const disposable = this.process.onData(callback); - return () => { - disposable.dispose(); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); - return () => { - disposable.dispose(); - }; - } -} - -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - 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.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: globalThis.process.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); - }), - } satisfies PtyAdapterShape; - }), -); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts similarity index 86% rename from apps/server/src/terminal/Layers/Manager.test.ts rename to apps/server/src/terminal/Manager.test.ts index 2ebf8481957e..3a1cabc4a270 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -8,6 +8,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -22,31 +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 * 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) { @@ -54,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 }); } @@ -73,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); @@ -86,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"; @@ -104,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, }), ); @@ -122,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, }), }); @@ -199,11 +203,11 @@ const multiTerminalHistoryLogPath = ( interface CreateManagerOptions { shellResolver?: () => string; - platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -215,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>; } @@ -234,12 +238,11 @@ 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, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } @@ -269,12 +272,13 @@ const createManager = ( }), ); +const withHostPlatform = (platform: NodeJS.Platform) => + Layer.succeed(HostProcessPlatform, platform); + it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, )("TerminalManager", (it) => { - const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; - it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -318,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(); @@ -414,10 +443,45 @@ it.layer( fs.writeFileString(filePath, contents), ); - itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => + 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; + + const path = yield* Path.Path; + const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); @@ -429,9 +493,11 @@ it.layer( ); expect(error).toMatchObject({ - _tag: "TerminalCwdError", + _tag: "TerminalCwdStatError", cwd: blockedCwd, - reason: "statFailed", + cause: { + _tag: "PlatformError", + }, }); }), ); @@ -475,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(); @@ -745,7 +889,8 @@ it.layer( let inspect: { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; - } = { hasRunningSubprocess: false, childCommand: null }; + readonly processIds: ReadonlyArray; + } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; const { manager, getEvents } = yield* createManager(5, { subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, @@ -754,7 +899,7 @@ it.layer( yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - inspect = { hasRunningSubprocess: true, childCommand: "vim" }; + inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -767,7 +912,7 @@ it.layer( "1200 millis", ); - inspect = { hasRunningSubprocess: false, childCommand: null }; + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -788,7 +933,11 @@ it.layer( const { manager } = yield* createManager(5, { subprocessInspector: () => { checks += 1; - return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); + return Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }); }, subprocessPollIntervalMs: 20, }); @@ -1076,10 +1225,9 @@ it.layer( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const platform = yield* HostProcessPlatform; const missingShell = - process.platform === "win32" - ? "C:\\definitely\\missing-shell.exe" - : "/definitely/missing-shell -l"; + platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => missingShell, }); @@ -1090,10 +1238,10 @@ it.layer( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); expect(ptyAdapter.spawnInputs[0]?.shell).toBe( - process.platform === "win32" ? missingShell : "/definitely/missing-shell", + platform === "win32" ? missingShell : "/definitely/missing-shell", ); - if (process.platform === "win32") { + if (platform === "win32") { expect( ptyAdapter.spawnInputs.some( (input) => @@ -1115,13 +1263,12 @@ it.layer( it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); yield* manager.open(openInput()); @@ -1136,15 +1283,16 @@ it.layer( it.effect("falls back to built-in PowerShell by absolute path on Windows", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, + shellResolver: () => "C:\\missing\\custom-shell.exe", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, - shellResolver: () => "C:\\missing\\custom-shell.exe", - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), @@ -1164,46 +1312,25 @@ it.layer( it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { - const originalValues = new Map(); - const setEnv = (key: string, value: string | undefined) => { - if (!originalValues.has(key)) { - originalValues.set(key, process.env[key]); - } - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - }; - const restoreEnv = () => { - for (const [key, value] of originalValues) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - setEnv("PORT", "5173"); - setEnv("T3CODE_PORT", "3773"); - setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); - setEnv("TEST_TERMINAL_KEEP", "keep-me"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; - try { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - } finally { - restoreEnv(); - } + expect(spawnInput.env.PORT).toBeUndefined(); + expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); + expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); + // Arbitrary host env vars must pass through — terminals inherit the + // user's environment apart from the explicit blocklist. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), ); @@ -1231,7 +1358,7 @@ it.layer( it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { - if (process.platform === "win32") return; + if ((yield* HostProcessPlatform) === "win32") return; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => "/bin/zsh", }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts new file mode 100644 index 000000000000..6347fdfc64d6 --- /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/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts new file mode 100644 index 000000000000..ed87440d4996 --- /dev/null +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -0,0 +1,88 @@ +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 * as NodePtyAdapter from "./NodePtyAdapter.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; + +const spawn = vi.fn(() => ({ + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("node-pty", () => ({ spawn })); + +const testLayer = NodePtyAdapter.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), +); + +it.effect("spawns through the public adapter with the provided host references", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn({ + shell: "powershell.exe", + args: ["-NoLogo"], + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + }); + + assert.equal(process.pid, 42); + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + ["-NoLogo"], + { + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + name: "xterm-color", + }, + ]); + }).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/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts new file mode 100644 index 000000000000..ac06e1edfab8 --- /dev/null +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -0,0 +1,165 @@ +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 * 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 = NodeModule.createRequire(import.meta.url); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + + const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); + const packageDir = path.dirname(packageJsonPath); + const candidates = [ + path.join(packageDir, "build", "Release", "spawn-helper"), + path.join(packageDir, "build", "Debug", "spawn-helper"), + path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + if (yield* fs.exists(candidate)) { + return candidate; + } + } + return null; +}).pipe(Effect.orElseSucceed(() => null)); + +const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; + if (didEnsureSpawnHelperExecutable) return; + + const helperPath = yield* resolveNodePtySpawnHelperPath; + if (!helperPath) return; + didEnsureSpawnHelperExecutable = true; + + if (!(yield* fs.exists(helperPath))) { + return; + } + + // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. + yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); +}); + +class NodePtyProcess implements PtyAdapter.PtyProcess { + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + this.process.write(data); + } + + resize(cols: number, rows: number): void { + this.process.resize(cols, rows); + } + + kill(signal?: string): void { + this.process.kill(signal); + } + + onData(callback: (data: string) => void): () => void { + const disposable = this.process.onData(callback); + return () => { + disposable.dispose(); + }; + } + + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + const disposable = this.process.onExit((event) => { + callback({ + exitCode: event.exitCode, + signal: event.signal ?? null, + }); + }); + return () => { + disposable.dispose(); + }; + } +} + +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, + }), + }).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 000000000000..f4ac9516537d --- /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 7af78810efa7..67147035bb5d 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 51c66f49f7cc..000000000000 --- 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 { - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalClearInput, - TerminalCloseInput, - TerminalEvent, - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalMetadataStreamEvent, - TerminalNotRunningError, - 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 0c53dbecea0c..c8fe4ead3be7 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 c06a0bfc5604..453bb62b728e 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. * @@ -15,9 +15,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +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, @@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ "-p", @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", ], - { - env: claudeEnvironment, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: claudeEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) @@ -232,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 cf0ad7d57815..24054a958701 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 bebd0acf8008..0e68994fd3dd 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,16 +9,13 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +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, @@ -44,12 +41,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); */ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { 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 = { readonly imagePaths: ReadonlyArray; @@ -119,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: [] }; @@ -180,7 +178,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", [ "exec", @@ -200,18 +198,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { - env: { - ...environment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), - }, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: resolvedEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: { + ...resolvedEnvironment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + }, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) @@ -282,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 c7ca9f7086e9..2dc4720dcadb 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 c4ef1af21d10..3e1f4eb8bbcf 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` @@ -59,9 +36,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { */ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; const runCursorJson = ({ operation, @@ -84,7 +62,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const outputRef = yield* Ref.make(""); const runtime = yield* makeCursorAcpRuntime({ cursorSettings, - environment, + environment: resolvedEnvironment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, @@ -110,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({ @@ -139,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, + }), ), ); @@ -156,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 58ce165752c8..85127b519b98 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 6d7ff8e872d1..1bb582163056 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 ba1f3a0435ce..558a8663b64d 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 b865b2e5ef57..1f94f970692c 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 @@ -99,10 +191,11 @@ interface SharedOpenCodeTextGenerationServerState { export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, - environment: NodeJS.ProcessEnv = process.env, + 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), ); @@ -134,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( @@ -208,7 +301,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, - environment, + environment: resolvedEnvironment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), @@ -216,7 +309,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ), @@ -239,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) { @@ -266,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, @@ -285,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 @@ -354,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 f186d934e527..9bccb9c1fc5b 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 d5d28e638ed1..e62a79afe787 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 1435bc522b8c..b67e8b93c4aa 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 a786f81b2c88..ad2911c20f76 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 70bb8655ea18..89f7c55d5863 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 adf991556d49..55aa8f38835e 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 c0e0f1876c46..dc58fc2543c6 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(); @@ -216,7 +388,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("disables SSH askpass for background upstream status fetches", () => + it.effect("makes background upstream status fetches non-interactive", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); @@ -225,15 +397,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const pathService = yield* Path.Path; const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); - const previousGitSsh = process.env.GIT_SSH; - const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE; - const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG; + const envKeys = [ + "GCM_INTERACTIVE", + "GIT_ASKPASS", + "GIT_SSH", + "GIT_TERMINAL_PROMPT", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "T3_TEST_SSH_ASKPASS_LOG", + ] as const; + const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); yield* fileSystem.writeFileString( sshWrapperPath, [ "#!/bin/sh", - 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GCM_INTERACTIVE=%s\\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_ASKPASS=%s\\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_TERMINAL_PROMPT=%s\\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS=%s\\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS_REQUIRE=%s\\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', "exit 1", "", ].join("\n"), @@ -245,29 +428,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* Effect.gen(function* () { process.env.GIT_SSH = sshWrapperPath; + process.env.GCM_INTERACTIVE = "always"; + process.env.GIT_ASKPASS = "git-askpass"; + process.env.GIT_TERMINAL_PROMPT = "1"; + process.env.SSH_ASKPASS = "ssh-askpass"; process.env.SSH_ASKPASS_REQUIRE = "force"; process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never"); + assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [ + "GCM_INTERACTIVE=never", + "GIT_ASKPASS=", + "GIT_TERMINAL_PROMPT=0", + "SSH_ASKPASS=", + "SSH_ASKPASS_REQUIRE=never", + ]); }).pipe( Effect.ensuring( Effect.sync(() => { - if (previousGitSsh === undefined) { - delete process.env.GIT_SSH; - } else { - process.env.GIT_SSH = previousGitSsh; - } - if (previousAskpassRequire === undefined) { - delete process.env.SSH_ASKPASS_REQUIRE; - } else { - process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire; - } - if (previousAskpassLog === undefined) { - delete process.env.T3_TEST_SSH_ASKPASS_LOG; - } else { - process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog; + for (const key of envKeys) { + const previous = previousEnv.get(key); + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } } }), ), @@ -299,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(); @@ -399,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 33d009b9dc27..a406cbce5491 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; @@ -54,6 +53,10 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + GCM_INTERACTIVE: "never", + GIT_ASKPASS: "", + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; @@ -96,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; @@ -322,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 { @@ -336,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 { @@ -398,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; @@ -447,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; @@ -570,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, @@ -610,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, }); } @@ -631,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(); @@ -651,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, @@ -665,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( @@ -678,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( [ @@ -697,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" }, @@ -710,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, }); } @@ -739,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, @@ -752,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, @@ -795,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, + }), ); }), ); @@ -873,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) { @@ -1020,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* ( @@ -1055,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, @@ -1126,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; @@ -1170,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(); @@ -1280,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] = @@ -1422,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, @@ -1465,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, @@ -1540,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; @@ -1610,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, [ @@ -1658,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", @@ -1687,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", @@ -1704,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`; @@ -1811,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, @@ -1837,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, @@ -1857,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([ @@ -1902,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()), @@ -1922,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, @@ -1944,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( @@ -1961,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( @@ -1985,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] = @@ -2121,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, }); @@ -2140,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; @@ -2152,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, @@ -2163,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( @@ -2177,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, [ @@ -2204,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", @@ -2215,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", @@ -2223,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"]; @@ -2233,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( [ @@ -2338,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, [ @@ -2350,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 }); @@ -2364,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", @@ -2407,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 1885a49ce923..f2daf7935027 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 03c09c16be85..7a531a5adcc0 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 228688557379..0bf95c2ffba9 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 b58d64e435a5..675d20cb82c9 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 a4caf7d32308..52db6f9b1fb2 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 aac4beb7e321..04f7fcffcda0 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 3e5ee2347ce9..6abce9a3ef31 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 ba919a5f4355..0a28f9c9b2ce 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 38006b4b6034..9febacf2256a 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 e92ab374f4e6..032e48e46122 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"; @@ -17,6 +19,7 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; +import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -42,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, @@ -54,6 +69,7 @@ function makeTestLayer(state: { remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; + remoteStatusRefreshUpstreamValues?: Array; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -64,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: () => @@ -149,6 +166,72 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + }; + 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: () => + Effect.suspend(() => { + state.remoteStatusCalls += 1; + return state.failRemoteStatus + ? Effect.fail( + new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + cwd: "/repo", + detail: "remote status failed", + }), + ) + : Effect.succeed(state.currentRemoteStatus); + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/partial-refresh", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 3, + }; + state.failRemoteStatus = true; + + const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.isTrue(Exit.isFailure(refreshExit)); + assert.deepStrictEqual(cached, baseStatus); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("refreshes only the cached local snapshot when requested", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -219,7 +302,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -286,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", () => { @@ -376,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, @@ -420,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 9cd5a6c337c3..c238154f58c7 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,316 +178,366 @@ 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()); - - const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, - ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); +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 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 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; + }); - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } - return 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; + 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: "remoteUpdated", - remote, - }, - }); - } + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } - return 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 loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( - cwd: string, - ) { - const remote = yield* workflow.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote); - }); + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } - 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); - }); + return mergeGitStatusParts(local, remote); + }); - const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( - function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.remote) { - return cached.remote.value; - } - return yield* loadRemoteStatus(cwd); - }, - ); + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); - - const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( - "VcsStatusBroadcaster.getStatus", - )(function* (input) { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const [local, remote] = yield* Effect.all([ - getOrLoadLocalStatus(cwd), - getOrLoadRemoteStatus(cwd), - ]); - return mergeGitStatusParts(local, remote); - }); + 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)); + 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)); - const [local, remote] = yield* Effect.all([ - refreshLocalStatus(cwd), - refreshRemoteStatus(cwd), - ]); - return mergeGitStatusParts(local, remote); - }); + 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/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts deleted file mode 100644 index 54981468164b..000000000000 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ /dev/null @@ -1,641 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as OS from "node:os"; -import fsPromises from "node:fs/promises"; -import type { Dirent } from "node:fs"; - -import * as Cache from "effect/Cache"; -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 Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - type FilesystemBrowseInput, - type ProjectEntry, - type ProjectListDirectoryInput, -} from "@t3tools/contracts"; -import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { - insertRankedSearchResult, - normalizeSearchQuery, - scoreQueryMatch, - type RankedSearchResult, -} from "@t3tools/shared/searchRanking"; - -import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; -import { - WorkspaceEntries, - WorkspaceEntriesBrowseError, - WorkspaceEntriesError, - type WorkspaceEntriesShape, -} from "../Services/WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const WORKSPACE_CACHE_TTL_MS = 15_000; -const WORKSPACE_CACHE_MAX_KEYS = 4; -const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; -const WORKSPACE_LIST_DIRECTORY_MAX_ENTRIES = 2_000; -const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; -const IGNORED_DIRECTORY_NAMES = new Set([ - ".git", - ".convex", - "node_modules", - ".next", - ".turbo", - ".vite-plus", - "dist", - "build", - "out", - ".cache", -]); - -interface WorkspaceIndex { - scannedAt: number; - entries: SearchableWorkspaceEntry[]; - truncated: boolean; -} - -interface SearchableWorkspaceEntry extends ProjectEntry { - normalizedPath: string; - normalizedName: string; -} - -type RankedWorkspaceEntry = RankedSearchResult; - -function toPosixPath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return OS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); - } - return input; -} - -function parentPathOf(input: string): string | undefined { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return undefined; - } - return input.slice(0, separatorIndex); -} - -function basenameOf(input: string): string { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return input; - } - return input.slice(separatorIndex + 1); -} - -// Dotenv-style files (`.env`, `.env.local`, `.env.production`, `.envrc`, ...) -// are routinely gitignored, but users still need to view and edit them in the -// file browser, so they are exempt from VCS-ignore filtering when listing a -// directory. -function isEnvFileName(name: string): boolean { - return name === ".env" || name === ".envrc" || name.startsWith(".env."); -} - -function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { - const normalizedPath = entry.path.toLowerCase(); - return { - ...entry, - normalizedPath, - normalizedName: basenameOf(normalizedPath), - }; -} - -function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { - if (!query) { - return entry.kind === "directory" ? 0 : 1; - } - - const { normalizedPath, normalizedName } = entry; - - const scores = [ - scoreQueryMatch({ - value: normalizedName, - query, - exactBase: 0, - prefixBase: 2, - includesBase: 5, - fuzzyBase: 100, - }), - scoreQueryMatch({ - value: normalizedPath, - query, - exactBase: 1, - prefixBase: 3, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 200, - boundaryMarkers: ["/"], - }), - ].filter((score): score is number => score !== null); - - if (scores.length === 0) { - return null; - } - - return Math.min(...scores); -} - -function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); -} - -function directoryAncestorsOf(relativePath: string): string[] { - const segments = relativePath.split("/").filter((segment) => segment.length > 0); - if (segments.length <= 1) return []; - - const directories: string[] = []; - for (let index = 1; index < segments.length; index += 1) { - directories.push(segments.slice(0, index).join("/")); - } - return directories; -} - -const resolveBrowseTarget = ( - input: FilesystemBrowseInput, - pathService: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - if (process.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 pathService.resolve(expandHomePath(input.partialPath, pathService)); - } - - 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 pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); - }); - -export const makeWorkspaceEntries = Effect.gen(function* () { - const path = yield* Path.Path; - const vcsRegistry = yield* VcsDriverRegistry; - const workspacePaths = yield* WorkspacePaths; - - const isInsideVcsWorkTree = (cwd: string): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.map((handle) => handle !== null), - Effect.orElseSucceed(() => false), - ); - - const filterVcsIgnoredPaths = ( - cwd: string, - relativePaths: string[], - ): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.flatMap((handle) => - handle - ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.orElseSucceed(() => relativePaths), - ) - : Effect.succeed(relativePaths), - ), - Effect.orElseSucceed(() => relativePaths), - ); - - const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( - function* (cwd: string) { - const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.orElseSucceed(() => null)); - if (!vcs) { - return null; - } - - const listedFiles = yield* vcs.driver - .listWorkspaceFiles(cwd) - .pipe(Effect.orElseSucceed(() => null)); - - if (!listedFiles) { - return null; - } - - const listedPaths: Array = []; - for (const rawEntry of listedFiles.paths) { - const entry = toPosixPath(rawEntry); - if (entry.length > 0 && !isPathInIgnoredDirectory(entry)) { - listedPaths.push(entry); - } - } - const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), - ); - - const directorySet = new Set(); - for (const filePath of filePaths) { - for (const directoryPath of directoryAncestorsOf(filePath)) { - if (!isPathInIgnoredDirectory(directoryPath)) { - directorySet.add(directoryPath); - } - } - } - - const directoryEntries = [...directorySet] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (directoryPath): ProjectEntry => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - }), - ) - .map(toSearchableWorkspaceEntry); - const fileEntries = [...new Set(filePaths)] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (filePath): ProjectEntry => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - }), - ) - .map(toSearchableWorkspaceEntry); - - const now = yield* DateTime.now; - const entries = [...directoryEntries, ...fileEntries]; - return { - scannedAt: now.epochMilliseconds, - entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), - truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, - }; - }, - ); - - const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* ( - cwd: string, - relativeDir: string, - ): Effect.fn.Return< - { readonly relativeDir: string; readonly dirents: Dirent[] | null }, - WorkspaceEntriesError - > { - return yield* Effect.tryPromise({ - try: async () => { - const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; - const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); - return { relativeDir, dirents }; - }, - catch: (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.readDirectoryEntries", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }).pipe( - Effect.catchIf( - () => relativeDir.length > 0, - () => Effect.succeed({ relativeDir, dirents: null }), - ), - ); - }); - - const buildWorkspaceIndexFromFilesystem = Effect.fn( - "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", - )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); - - let pendingDirectories: string[] = [""]; - const entries: SearchableWorkspaceEntry[] = []; - let truncated = false; - - while (pendingDirectories.length > 0 && !truncated) { - const currentDirectories = pendingDirectories; - pendingDirectories = []; - - const directoryEntries = yield* Effect.forEach( - currentDirectories, - (relativeDir) => readDirectoryEntries(cwd, relativeDir), - { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY }, - ); - - const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { - const { relativeDir, dirents } = directoryEntry; - if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; - - dirents.sort((left, right) => left.name.localeCompare(right.name)); - const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; - for (const dirent of dirents) { - if (!dirent.name || dirent.name === "." || dirent.name === "..") { - continue; - } - if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { - continue; - } - if (!dirent.isDirectory() && !dirent.isFile()) { - continue; - } - - const relativePath = toPosixPath( - relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, - ); - if (isPathInIgnoredDirectory(relativePath)) { - continue; - } - candidates.push({ dirent, relativePath }); - } - return candidates; - }); - - const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => - candidateEntries.map((entry) => entry.relativePath), - ); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) - : null; - - for (const candidateEntries of candidateEntriesByDirectory) { - for (const candidate of candidateEntries) { - if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { - continue; - } - - const entry = toSearchableWorkspaceEntry({ - path: candidate.relativePath, - kind: candidate.dirent.isDirectory() ? "directory" : "file", - parentPath: parentPathOf(candidate.relativePath), - }); - entries.push(entry); - - if (candidate.dirent.isDirectory()) { - pendingDirectories.push(candidate.relativePath); - } - - if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { - truncated = true; - break; - } - } - - if (truncated) { - break; - } - } - } - - const now = yield* DateTime.now; - return { - scannedAt: now.epochMilliseconds, - entries, - truncated, - }; - }); - - const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( - cwd: string, - ): Effect.fn.Return { - const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); - if (vcsIndexed) { - return vcsIndexed; - } - return yield* buildWorkspaceIndexFromFilesystem(cwd); - }); - - const workspaceIndexCache = yield* Cache.makeWith( - buildWorkspaceIndex, - { - capacity: WORKSPACE_CACHE_MAX_KEYS, - timeToLive: (exit) => - Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, - }, - ); - - 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, - }), - ), - ); - }); - - const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( - function* (cwd) { - const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( - Effect.orElseSucceed(() => cwd), - ); - yield* Cache.invalidate(workspaceIndexCache, cwd); - if (normalizedCwd !== cwd) { - yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); - } - }, - ); - - const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( - function* (input) { - const resolvedInputPath = yield* resolveBrowseTarget(input, path); - const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; - const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); - const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); - - const dirents = yield* Effect.tryPromise({ - try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.browse.readDirectory", - detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }), - }).pipe( - // The user can deny macOS TCC prompts for the target dir (Documents, - // Downloads, Music, etc.); surface an empty listing instead of an - // error so the caller doesn't retry-loop the prompt. - Effect.catchIf( - (error) => { - const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; - return code === "EACCES" || code === "EPERM"; - }, - () => Effect.succeed([]), - ), - ); - - const showHidden = endsWithSeparator || prefix.startsWith("."); - const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; - for (const dirent of dirents) { - if ( - dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && - (showHidden || !dirent.name.startsWith(".")) - ) { - entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), - }); - } - } - - return { - parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), - }; - }, - ); - - const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( - function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( - Effect.map((index) => { - const normalizedQuery = normalizeSearchQuery(input.query, { - trimLeadingPattern: /^[@./]+/, - }); - const limit = Math.max(0, Math.floor(input.limit)); - const rankedEntries: RankedWorkspaceEntry[] = []; - let matchedEntryCount = 0; - - for (const entry of index.entries) { - const score = scoreEntry(entry, normalizedQuery); - if (score === null) { - continue; - } - - matchedEntryCount += 1; - insertRankedSearchResult( - rankedEntries, - { item: entry, score, tieBreaker: entry.path }, - limit, - ); - } - - return { - entries: rankedEntries.map((candidate) => candidate.item), - truncated: index.truncated || matchedEntryCount > limit, - }; - }), - ); - }, - ); - - const listDirectory: WorkspaceEntriesShape["listDirectory"] = Effect.fn( - "WorkspaceEntries.listDirectory", - )(function* (input: ProjectListDirectoryInput) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - - // Resolve the requested directory, allowing the workspace root itself - // (omitted/empty relativePath) while still rejecting traversal outside root. - const relativeDir = input.relativePath - ? (yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: normalizedCwd, - relativePath: input.relativePath, - })).relativePath - : ""; - const absoluteDir = relativeDir ? path.join(normalizedCwd, relativeDir) : normalizedCwd; - - const dirents = yield* Effect.tryPromise({ - try: () => fsPromises.readdir(absoluteDir, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.listDirectory", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); - - const candidates: Array<{ relativePath: string; isDirectory: boolean }> = []; - for (const dirent of dirents) { - if (!dirent.name || dirent.name === "." || dirent.name === "..") { - continue; - } - const isDirectory = dirent.isDirectory(); - if (isDirectory && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { - continue; - } - if (!isDirectory && !dirent.isFile()) { - continue; - } - const relativePath = toPosixPath( - relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, - ); - if (isPathInIgnoredDirectory(relativePath)) { - continue; - } - candidates.push({ relativePath, isDirectory }); - } - - const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(normalizedCwd); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set( - yield* filterVcsIgnoredPaths( - normalizedCwd, - candidates.map((candidate) => candidate.relativePath), - ), - ) - : null; - - const directoryEntries: ProjectEntry[] = []; - const fileEntries: ProjectEntry[] = []; - for (const candidate of candidates) { - const keepDespiteVcsIgnore = - !candidate.isDirectory && isEnvFileName(basenameOf(candidate.relativePath)); - if ( - allowedPathSet && - !allowedPathSet.has(candidate.relativePath) && - !keepDespiteVcsIgnore - ) { - continue; - } - const entry: ProjectEntry = { - path: candidate.relativePath, - kind: candidate.isDirectory ? "directory" : "file", - parentPath: parentPathOf(candidate.relativePath), - }; - (candidate.isDirectory ? directoryEntries : fileEntries).push(entry); - } - - directoryEntries.sort((left, right) => left.path.localeCompare(right.path)); - fileEntries.sort((left, right) => left.path.localeCompare(right.path)); - const entries = [...directoryEntries, ...fileEntries]; - const truncated = entries.length > WORKSPACE_LIST_DIRECTORY_MAX_ENTRIES; - - return { - ...(relativeDir ? { relativePath: relativeDir } : {}), - entries: truncated ? entries.slice(0, WORKSPACE_LIST_DIRECTORY_MAX_ENTRIES) : entries, - truncated, - }; - }); - - return { - browse, - invalidate, - listDirectory, - search, - } satisfies WorkspaceEntriesShape; -}); - -export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts deleted file mode 100644 index 3011adaf5f4b..000000000000 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ /dev/null @@ -1,414 +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 { ServerConfig } from "../../config.ts"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; - -const ProjectLayer = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), -); - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-workspace-files-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), -); - -const makeTempDir = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-workspace-files-", - }); -}); - -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents = "", -) { - 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)("WorkspaceFileSystemLive", (it) => { - describe("writeFile", () => { - it.effect("writes files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n", - }); - const saved = yield* fileSystem - .readFileString(path.join(cwd, "plans/effect-rpc.md")) - .pipe(Effect.orDie); - - expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); - expect(saved).toBe("# Plan\n"); - }), - ); - - it.effect("invalidates workspace entry search cache after writes", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); - - const beforeWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); - expect(beforeWrite).toEqual({ - entries: [], - truncated: false, - }); - - yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n", - }); - - const afterWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); - expect(afterWrite.entries).toEqual( - expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), - ); - expect(afterWrite.truncated).toBe(false); - }), - ); - - it.effect("rejects writes outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - - const error = yield* workspaceFileSystem - .writeFile({ - cwd, - relativePath: "../escape.md", - contents: "# nope\n", - }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - - const escapedPath = path.resolve(cwd, "..", "escape.md"); - const escapedStat = yield* fileSystem - .stat(escapedPath) - .pipe(Effect.orElseSucceed(() => null)); - expect(escapedStat).toBeNull(); - }), - ); - - it.effect("writes base64-encoded contents as raw bytes", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]); - - yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "assets/logo.png", - contents: Buffer.from(bytes).toString("base64"), - encoding: "base64", - }); - - const saved = yield* fileSystem - .readFile(path.join(cwd, "assets/logo.png")) - .pipe(Effect.orDie); - expect([...saved]).toEqual([...bytes]); - }), - ); - }); - - describe("readFile", () => { - it.effect("reads text files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/main.ts", "export const answer = 42;\n"); - - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: "src/main.ts", - }); - - expect(result.encoding).toBe("utf8"); - expect(result.contents).toBe("export const answer = 42;\n"); - expect(result.truncated).toBe(false); - expect(result.byteSize).toBe(26); - expect(result.relativePath).toBe("src/main.ts"); - expect(result.mediaType).toBeUndefined(); - }), - ); - - it.effect("truncates text files beyond the requested byte cap", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "big.txt", "abcdefgh"); - - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: "big.txt", - maxBytes: 4, - }); - - expect(result.encoding).toBe("utf8"); - expect(result.contents).toBe("abcd"); - expect(result.truncated).toBe(true); - expect(result.byteSize).toBe(8); - }), - ); - - it.effect("returns base64 contents and a media type for images", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); - yield* fileSystem.writeFile(path.join(cwd, "logo.png"), bytes).pipe(Effect.orDie); - - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: "logo.png", - }); - - expect(result.encoding).toBe("base64"); - expect(result.mediaType).toBe("image/png"); - expect(result.truncated).toBe(false); - expect(result.contents).toBe(Buffer.from(bytes).toString("base64")); - }), - ); - - it.effect("rejects reading a directory", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/main.ts", "export {};\n"); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "src" }) - .pipe(Effect.flip); - - expect(error._tag).toBe("WorkspaceFileSystemError"); - if (error._tag === "WorkspaceFileSystemError") { - expect(error.detail).toContain("directory"); - } - }), - ); - - it.effect("rejects reads outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "../escape.md" }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - }), - ); - }); - - describe("deletePath", () => { - it.effect("deletes a file", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/old.ts", "export {};\n"); - - yield* workspaceFileSystem.deletePath({ cwd, relativePath: "src/old.ts" }); - - const exists = yield* fileSystem.exists(path.join(cwd, "src/old.ts")).pipe(Effect.orDie); - expect(exists).toBe(false); - }), - ); - - it.effect("deletes a directory recursively", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "pkg/nested/a.ts", "export {};\n"); - yield* writeTextFile(cwd, "pkg/b.ts", "export {};\n"); - - yield* workspaceFileSystem.deletePath({ cwd, relativePath: "pkg" }); - - const exists = yield* fileSystem.exists(path.join(cwd, "pkg")).pipe(Effect.orDie); - expect(exists).toBe(false); - }), - ); - - it.effect("rejects deletes outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - - const error = yield* workspaceFileSystem - .deletePath({ cwd, relativePath: "../escape" }) - .pipe(Effect.flip); - - expect(error.message).toContain("must be relative to the project root"); - }), - ); - }); - - describe("createDirectory", () => { - it.effect("creates nested directories", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - - const result = yield* workspaceFileSystem.createDirectory({ - cwd, - relativePath: "src/components", - }); - - expect(result.relativePath).toBe("src/components"); - const stat = yield* fileSystem.stat(path.join(cwd, "src/components")).pipe(Effect.orDie); - expect(stat.type).toBe("Directory"); - }), - ); - - it.effect("rejects directories outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - - const error = yield* workspaceFileSystem - .createDirectory({ cwd, relativePath: "../escape" }) - .pipe(Effect.flip); - - expect(error.message).toContain("must be relative to the project root"); - }), - ); - }); - - describe("movePath", () => { - it.effect("renames a file within the same directory", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/old.ts", "export const a = 1;\n"); - - const result = yield* workspaceFileSystem.movePath({ - cwd, - fromRelativePath: "src/old.ts", - toRelativePath: "src/new.ts", - }); - - expect(result).toEqual({ fromRelativePath: "src/old.ts", toRelativePath: "src/new.ts" }); - const oldExists = yield* fileSystem.exists(path.join(cwd, "src/old.ts")).pipe(Effect.orDie); - const moved = yield* fileSystem - .readFileString(path.join(cwd, "src/new.ts")) - .pipe(Effect.orDie); - expect(oldExists).toBe(false); - expect(moved).toBe("export const a = 1;\n"); - }), - ); - - it.effect("moves a file into another directory, creating parents", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "a.ts", "export {};\n"); - - yield* workspaceFileSystem.movePath({ - cwd, - fromRelativePath: "a.ts", - toRelativePath: "lib/nested/a.ts", - }); - - const moved = yield* fileSystem - .exists(path.join(cwd, "lib/nested/a.ts")) - .pipe(Effect.orDie); - expect(moved).toBe(true); - }), - ); - - it.effect("refuses to overwrite an existing destination", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "a.ts", "a\n"); - yield* writeTextFile(cwd, "b.ts", "b\n"); - - const error = yield* workspaceFileSystem - .movePath({ cwd, fromRelativePath: "a.ts", toRelativePath: "b.ts" }) - .pipe(Effect.flip); - - expect(error._tag).toBe("WorkspaceFileSystemError"); - if (error._tag === "WorkspaceFileSystemError") { - expect(error.detail).toContain("already exists"); - } - }), - ); - - it.effect("rejects moves outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "a.ts", "a\n"); - - const error = yield* workspaceFileSystem - .movePath({ cwd, fromRelativePath: "a.ts", toRelativePath: "../escape.ts" }) - .pipe(Effect.flip); - - expect(error.message).toContain("must be relative to the project root"); - }), - ); - }); -}); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts deleted file mode 100644 index 336fc56da2c6..000000000000 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ /dev/null @@ -1,291 +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 { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; -import { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts"; - -const DEFAULT_READ_MAX_BYTES = 1024 * 1024; // 1 MiB -const HARD_READ_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB -const BINARY_SNIFF_BYTES = 8_000; - -const IMAGE_MEDIA_TYPE_BY_EXTENSION: Record = (() => { - const map: Record = {}; - for (const [mimeType, extension] of Object.entries(IMAGE_EXTENSION_BY_MIME_TYPE)) { - map[extension] = mimeType; - } - map[".ico"] = "image/x-icon"; - return map; -})(); - -function detectImageMediaType(relativePath: string): string | undefined { - const match = /\.[a-z0-9]+$/i.exec(relativePath); - if (!match) { - return undefined; - } - const extension = match[0].toLowerCase(); - if (!SAFE_IMAGE_FILE_EXTENSIONS.has(extension)) { - return undefined; - } - return IMAGE_MEDIA_TYPE_BY_EXTENSION[extension]; -} - -function isProbablyBinary(buffer: Buffer): boolean { - const limit = Math.min(buffer.length, BINARY_SNIFF_BYTES); - for (let index = 0; index < limit; index += 1) { - if (buffer[index] === 0) { - return true; - } - } - return false; -} - -export const makeWorkspaceFileSystem = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - const workspaceEntries = yield* WorkspaceEntries; - - 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, - }), - ), - ); - const writeEffect = - input.encoding === "base64" - ? fileSystem.writeFile( - target.absolutePath, - new Uint8Array(Buffer.from(input.contents, "base64")), - ) - : fileSystem.writeFileString(target.absolutePath, input.contents); - yield* writeEffect.pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", - detail: cause.message, - cause, - }), - ), - ); - yield* workspaceEntries.invalidate(input.cwd); - return { relativePath: target.relativePath }; - }); - - const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( - function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - const requestedMaxBytes = input.maxBytes ?? DEFAULT_READ_MAX_BYTES; - const maxBytes = Math.min(Math.max(1, requestedMaxBytes), HARD_READ_MAX_BYTES); - - const read = yield* Effect.tryPromise({ - try: async () => { - const handle = await fsPromises.open(target.absolutePath, "r"); - try { - const stat = await handle.stat(); - if (stat.isDirectory()) { - return { kind: "directory" as const }; - } - const byteSize = stat.size; - const bytesToRead = Math.min(byteSize, maxBytes); - const buffer = Buffer.alloc(bytesToRead); - if (bytesToRead > 0) { - await handle.read(buffer, 0, bytesToRead, 0); - } - return { - kind: "file" as const, - buffer, - byteSize, - truncated: byteSize > bytesToRead, - }; - } 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, - }), - }); - - if (read.kind === "directory") { - return yield* new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.readFile", - detail: "Path refers to a directory, not a file.", - }); - } - - const mediaType = detectImageMediaType(target.relativePath); - const isBinary = mediaType !== undefined || isProbablyBinary(read.buffer); - - if (isBinary) { - return { - relativePath: target.relativePath, - encoding: "base64" as const, - contents: read.buffer.toString("base64"), - byteSize: read.byteSize, - truncated: read.truncated, - ...(mediaType ? { mediaType } : {}), - }; - } - - return { - relativePath: target.relativePath, - encoding: "utf8" as const, - contents: read.buffer.toString("utf8"), - byteSize: read.byteSize, - truncated: read.truncated, - }; - }, - ); - - const deletePath: WorkspaceFileSystemShape["deletePath"] = Effect.fn( - "WorkspaceFileSystem.deletePath", - )(function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - yield* fileSystem.remove(target.absolutePath, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.deletePath", - detail: cause.message, - cause, - }), - ), - ); - yield* workspaceEntries.invalidate(input.cwd); - return { relativePath: target.relativePath }; - }); - - const createDirectory: WorkspaceFileSystemShape["createDirectory"] = Effect.fn( - "WorkspaceFileSystem.createDirectory", - )(function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - yield* fileSystem.makeDirectory(target.absolutePath, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.createDirectory", - detail: cause.message, - cause, - }), - ), - ); - yield* workspaceEntries.invalidate(input.cwd); - return { relativePath: target.relativePath }; - }); - - const movePath: WorkspaceFileSystemShape["movePath"] = Effect.fn("WorkspaceFileSystem.movePath")( - function* (input) { - const from = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.fromRelativePath, - }); - const to = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.toRelativePath, - }); - - if (from.relativePath === to.relativePath) { - return { fromRelativePath: from.relativePath, toRelativePath: to.relativePath }; - } - - const makeError = (operation: string, detail: string, cause?: unknown) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.toRelativePath, - operation, - detail, - ...(cause === undefined ? {} : { cause }), - }); - - const destinationExists = yield* fileSystem - .exists(to.absolutePath) - .pipe( - Effect.mapError((cause) => - makeError("workspaceFileSystem.movePath.exists", cause.message, cause), - ), - ); - if (destinationExists) { - return yield* makeError( - "workspaceFileSystem.movePath", - `A file or folder already exists at ${to.relativePath}.`, - ); - } - - yield* fileSystem - .makeDirectory(path.dirname(to.absolutePath), { recursive: true }) - .pipe( - Effect.mapError((cause) => - makeError("workspaceFileSystem.movePath.makeDirectory", cause.message, cause), - ), - ); - yield* fileSystem - .rename(from.absolutePath, to.absolutePath) - .pipe( - Effect.mapError((cause) => - makeError("workspaceFileSystem.movePath.rename", cause.message, cause), - ), - ); - yield* workspaceEntries.invalidate(input.cwd); - return { fromRelativePath: from.relativePath, toRelativePath: to.relativePath }; - }, - ); - - return { - writeFile, - readFile, - deletePath, - createDirectory, - movePath, - } 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 f994aa875efe..000000000000 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as OS 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 OS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.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/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts deleted file mode 100644 index 67571685b9f2..000000000000 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * WorkspaceEntries - Effect service contract for cached workspace entry search. - * - * Owns indexed workspace entry search plus cache invalidation for workspace - * roots when the underlying filesystem changes. - * - * @module WorkspaceEntries - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - FilesystemBrowseInput, - FilesystemBrowseResult, - ProjectListDirectoryInput, - ProjectListDirectoryResult, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, -} from "@t3tools/contracts"; - -import type { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; - -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", - { - cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", - { - cwd: Schema.optional(Schema.String), - partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -/** - * WorkspaceEntriesShape - Service API for workspace entry search and cache - * invalidation. - */ -export interface WorkspaceEntriesShape { - /** - * Browse matching directories for the provided partial path. - */ - readonly browse: ( - input: FilesystemBrowseInput, - ) => Effect.Effect; - - /** - * Search indexed workspace entries for files and directories matching the - * provided query. - */ - readonly search: ( - input: ProjectSearchEntriesInput, - ) => Effect.Effect; - - /** - * List the immediate children of a single workspace-root-relative directory. - * - * Honors the same ignore rules as the search index (ignored directory names - * plus VCS ignore patterns) and rejects paths that escape the workspace root. - */ - readonly listDirectory: ( - input: ProjectListDirectoryInput, - ) => Effect.Effect< - ProjectListDirectoryResult, - WorkspaceEntriesError | WorkspacePathOutsideRootError - >; - - /** - * Drop any cached workspace entries for the given workspace root. - */ - readonly invalidate: (cwd: string) => Effect.Effect; -} - -/** - * WorkspaceEntries - Service tag for cached workspace entry search. - */ -export class WorkspaceEntries extends Context.Service()( - "t3/workspace/Services/WorkspaceEntries", -) {} diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts deleted file mode 100644 index 3754b7bf23fc..000000000000 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ /dev/null @@ -1,113 +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 { - ProjectCreateDirectoryInput, - ProjectCreateDirectoryResult, - ProjectDeletePathInput, - ProjectDeletePathResult, - ProjectMovePathInput, - ProjectMovePathResult, - 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()), - }, -) {} - -/** - * WorkspaceFileSystemShape - Service API for workspace-relative file operations. - */ -export interface WorkspaceFileSystemShape { - /** - * 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 - >; - - /** - * Read a file relative to the workspace root for read-only viewing. - * - * Caps the number of bytes returned, detects binary payloads (returning them - * base64-encoded with a best-effort media type), and rejects paths that escape - * the workspace root or point at a directory. - */ - readonly readFile: ( - input: ProjectReadFileInput, - ) => Effect.Effect< - ProjectReadFileResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; - - /** - * Delete a file or directory (recursively) relative to the workspace root. - * - * Rejects paths that escape the workspace root or target the root itself. - */ - readonly deletePath: ( - input: ProjectDeletePathInput, - ) => Effect.Effect< - ProjectDeletePathResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; - - /** - * Create a directory (and any missing parents) relative to the workspace root. - * - * Rejects paths that escape the workspace root or target the root itself. - */ - readonly createDirectory: ( - input: ProjectCreateDirectoryInput, - ) => Effect.Effect< - ProjectCreateDirectoryResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; - - /** - * Move or rename a file or directory within the workspace root. - * - * Creates the destination's parent directories as needed, refuses to - * overwrite an existing destination, and rejects paths that escape the root. - */ - readonly movePath: ( - input: ProjectMovePathInput, - ) => Effect.Effect< - ProjectMovePathResult, - 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 7c57ca19bd29..000000000000 --- 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/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts similarity index 58% rename from apps/server/src/workspace/Layers/WorkspaceEntries.test.ts rename to apps/server/src/workspace/WorkspaceEntries.test.ts index 2e2104c80d5f..a08350ed9591 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,28 +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 { it, afterEach, describe, expect, vi } from "@effect/vitest"; +import { FileFinder } from "@ff-labs/fff-node"; +import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; 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 VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.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 * 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(WorkspaceEntriesLive.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.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", }), ), @@ -70,20 +74,50 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); }); const appendSeparator = (input: string) => - input.endsWith("/") || input.endsWith("\\") - ? input - : `${input}${process.platform === "win32" ? "\\" : "/"}`; + Effect.map(HostProcessPlatform, (platform) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${platform === "win32" ? "\\" : "/"}`, + ); -it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { afterEach(() => { vi.restoreAllMocks(); }); + describe("list", () => { + it.effect("returns the complete cached workspace index", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "README.md"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.list({ cwd }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + { path: "src", kind: "directory" }, + { path: "src/components", kind: "directory" }, + { + path: "src/components/Composer.tsx", + kind: "file", + }, + { path: "README.md", kind: "file" }, + ]), + ); + expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { @@ -221,94 +255,39 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); - it.effect("deduplicates concurrent index builds for the same cwd", () => + it.effect("supports typo-resistant file search through fff", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); yield* writeTextFile(cwd, "src/components/Composer.tsx"); - let rootReadCount = 0; - let releaseRootRead: (() => void) | undefined; - const rootReadGate = new Promise((resolve) => { - releaseRootRead = resolve; - }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - if (args[0] === cwd) { - rootReadCount += 1; - await rootReadGate; - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const searches = yield* Effect.all( - [ - searchWorkspaceEntries({ cwd, query: "", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkScoped); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (rootReadCount > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseRootRead?.(); - yield* Fiber.join(searches); - - expect(rootReadCount).toBe(1); + const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/components/Composer.tsx" }), + ]), + ); }), ); - it.effect("limits concurrent directory reads while walking the filesystem", () => + it.effect("rebuilds the cached index after refresh fails", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" }); - yield* Effect.forEach( - Array.from({ length: 80 }, (_, index) => index), - (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"), - { discard: true }, - ); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); + yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); - let activeReads = 0; - let peakReads = 0; - let releaseReads: (() => void) | undefined; - const readsGate = new Promise((resolve) => { - releaseReads = resolve; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const createSpy = vi.spyOn(FileFinder, "create"); + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(1); + + vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ + ok: false, + error: "scan failed", }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await readsGate; - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const search = yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }).pipe( - Effect.forkScoped, - ); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (activeReads > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseReads?.(); - yield* Fiber.join(search); - - expect(peakReads).toBeLessThanOrEqual(32); + yield* workspaceEntries.refresh(cwd); + + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(2); }), ); }); @@ -316,7 +295,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); @@ -339,17 +318,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); yield* writeTextFile(cwd, ".config/settings.json", "{}"); yield* writeTextFile(cwd, "config/settings.json", "{}"); + const cwdWithSeparator = yield* appendSeparator(cwd); const directoryResult = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: cwdWithSeparator, }); const hiddenPrefixResult = yield* workspaceEntries.browse({ - partialPath: `${appendSeparator(cwd)}.c`, + partialPath: `${cwdWithSeparator}.c`, }); expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); @@ -362,7 +342,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("supports relative paths when cwd is provided", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); yield* writeTextFile(cwd, "packages/pkg.json", "{}"); @@ -381,7 +361,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("rejects relative paths without cwd", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const error = yield* workspaceEntries .browse({ @@ -389,112 +369,26 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (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'.", + ); }), ); it.effect("returns an empty listing when the OS denies directory access", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; 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: appendSeparator(cwd), + partialPath: yield* appendSeparator(cwd), }); expect(result).toEqual({ parentPath: cwd, entries: [] }); }), ); }); - - describe("listDirectory", () => { - it.effect("lists the workspace root with directories before files", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-root-" }); - yield* writeTextFile(cwd, "src/index.ts", "export {};"); - yield* writeTextFile(cwd, "README.md", "# readme"); - yield* writeTextFile(cwd, "node_modules/pkg/index.js", "module.exports = {};"); - yield* writeTextFile(cwd, ".git/HEAD", "ref: refs/heads/main"); - - const result = yield* workspaceEntries.listDirectory({ cwd }); - - expect(result.relativePath).toBeUndefined(); - expect(result.truncated).toBe(false); - expect(result.entries.map((entry) => entry.path)).toEqual(["src", "README.md"]); - expect(result.entries.find((entry) => entry.path === "src")?.kind).toBe("directory"); - expect(result.entries.find((entry) => entry.path === "README.md")?.kind).toBe("file"); - }), - ); - - it.effect("lists the immediate children of a subdirectory", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-subdir-" }); - yield* writeTextFile(cwd, "src/index.ts", "export {};"); - yield* writeTextFile(cwd, "src/components/Composer.tsx", "export {};"); - - const result = yield* workspaceEntries.listDirectory({ cwd, relativePath: "src" }); - - expect(result.relativePath).toBe("src"); - expect(result.entries.map((entry) => entry.path)).toEqual([ - "src/components", - "src/index.ts", - ]); - }), - ); - - it.effect("excludes gitignored entries for git repositories", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-gitignore-", git: true }); - yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); - yield* writeTextFile(cwd, "keep.ts", "export {};"); - yield* writeTextFile(cwd, "ignored.txt", "ignore me"); - - const result = yield* workspaceEntries.listDirectory({ cwd }); - const paths = result.entries.map((entry) => entry.path); - - expect(paths).toContain("keep.ts"); - expect(paths).not.toContain("ignored.txt"); - }), - ); - - it.effect("keeps gitignored dotenv files visible so they can be edited", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-env-", git: true }); - yield* writeTextFile(cwd, ".gitignore", ".env\n.env.local\nsecret.txt\n"); - yield* writeTextFile(cwd, "keep.ts", "export {};"); - yield* writeTextFile(cwd, ".env", "TOKEN=abc"); - yield* writeTextFile(cwd, ".env.local", "TOKEN=def"); - yield* writeTextFile(cwd, "secret.txt", "nope"); - - const result = yield* workspaceEntries.listDirectory({ cwd }); - const paths = result.entries.map((entry) => entry.path); - - expect(paths).toContain(".env"); - expect(paths).toContain(".env.local"); - expect(paths).toContain("keep.ts"); - // Non-env gitignored files stay hidden. - expect(paths).not.toContain("secret.txt"); - expect(result.entries.find((entry) => entry.path === ".env")?.kind).toBe("file"); - }), - ); - - it.effect("rejects directories outside the workspace root", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-escape-" }); - - const error = yield* workspaceEntries - .listDirectory({ cwd, relativePath: "../" }) - .pipe(Effect.flip); - - expect(error.message).toContain("must be relative to the project root"); - }), - ); - }); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts new file mode 100644 index 000000000000..7501cbe0eab2 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -0,0 +1,259 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as RcMap from "effect/RcMap"; +import * as Schema from "effect/Schema"; + +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectListEntriesInput, + ProjectListEntriesResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesWindowsPathUnsupportedError", + { + 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 WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesReadDirectoryError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + 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, + { + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + readonly list: ( + input: ProjectListEntriesInput, + ) => Effect.Effect; + readonly search: ( + input: ProjectSearchEntriesInput, + ) => Effect.Effect; + readonly refresh: (cwd: string) => Effect.Effect; + } +>()("t3/workspace/WorkspaceEntries") {} + +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; +} + +const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( + input: FilesystemBrowseInput, + path: Path.Path, +): 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)); + } + + 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; + + const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( + cwd: string, + ): Effect.fn.Return { + return yield* workspacePaths.normalizeWorkspaceRoot(cwd); + }); + + const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( + function* (cwd) { + const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( + Effect.orElseSucceed(() => cwd), + ); + 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.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); + }, + ); + + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesReadDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + parentPath, + cause, + }), + }).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; + for (const dirent of dirents) { + if ( + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")) + ) { + entries.push({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + }); + } + } + + return { + parentPath, + entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + + const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + const normalizedQuery = input.query + .trim() + .toLowerCase() + .replace(/^[@./]+/, ""); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.search(normalizedQuery, input.limit); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }, + ); + + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.list(); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }, + ); + + return WorkspaceEntries.of({ browse, list, refresh, search }); +}); + +export const layer = Layer.effect(WorkspaceEntries, make).pipe( + Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), +); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts new file mode 100644 index 000000000000..cecffbc1993d --- /dev/null +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -0,0 +1,268 @@ +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 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(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-files-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents = "", +) { + 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, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { + describe("readFile", () => { + it.effect("reads UTF-8 files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "src/index.ts", + }); + + expect(result).toEqual({ + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }), + ); + + it.effect("rejects reads outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "../escape.md" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect("rejects symlinks that resolve outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + ); + + 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")); + + 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"); + }), + ); + }); + + describe("writeFile", () => { + it.effect("writes files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + const saved = yield* fileSystem + .readFileString(path.join(cwd, "plans/effect-rpc.md")) + .pipe(Effect.orDie); + + expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); + expect(saved).toBe("# Plan\n"); + }), + ); + + it.effect("invalidates workspace entry search cache after writes", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); + + const beforeWrite = yield* workspaceEntries.list({ cwd }); + expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( + false, + ); + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + + const afterWrite = yield* workspaceEntries.list({ cwd }); + expect(afterWrite.entries).toEqual( + expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), + ); + expect(afterWrite.truncated).toBe(false); + }), + ); + + it.effect("rejects writes outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "../escape.md", + contents: "# nope\n", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + + const escapedPath = path.resolve(cwd, "..", "escape.md"); + const escapedStat = yield* fileSystem + .stat(escapedPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(escapedStat).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts new file mode 100644 index 000000000000..e2dc9cbbb390 --- /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 0a9252a7def2..4f3bc833b4c5 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 000000000000..5acf6677cdef --- /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 000000000000..9b7ed4e2453f --- /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 new file mode 100644 index 000000000000..db4d46851e7b --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -0,0 +1,326 @@ +import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as LayerMap from "effect/LayerMap"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import type { + ProjectEntry, + ProjectListEntriesResult, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; + +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; +const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; +const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; + +export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexCreateFailed", + { + cwd: Schema.String, + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to create the workspace search index for '${this.cwd}'.`; + } +} + +export class WorkspaceSearchIndexScanTimedOut extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexScanTimedOut", + { + cwd: Schema.String, + timeout: Schema.String, + }, +) { + override get message(): string { + return `Workspace search index for '${this.cwd}' did not finish scanning within ${this.timeout}`; + } +} + +export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexSearchFailed", + { + cwd: Schema.String, + queryLength: Schema.Number, + pageSize: Schema.Number, + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Workspace search failed for '${this.cwd}'.`; + } +} + +export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexRefreshFailed", + { + cwd: Schema.String, + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to refresh the workspace search index for '${this.cwd}'.`; + } +} + +export class WorkspaceSearchIndexDestroyFailed 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}'.`; + } +} + +export type WorkspaceSearchIndexError = + | WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndexSearchFailed + | WorkspaceSearchIndexRefreshFailed; + +export class WorkspaceSearchIndex extends Context.Service< + WorkspaceSearchIndex, + { + readonly list: () => Effect.Effect; + readonly search: ( + query: string, + limit: number, + ) => Effect.Effect; + readonly refresh: () => Effect.Effect< + void, + WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut + >; + } +>()("t3/workspace/WorkspaceSearchIndex") {} + +function toPosixPath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function trimDirectorySeparator(input: string): string { + return input.endsWith("/") ? input.slice(0, -1) : input; +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex); +} + +function toProjectEntry(item: MixedItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)); + if (!normalizedPath) { + return null; + } + + return { + path: normalizedPath, + kind: item.type, + }; +} + +function mapMixedSearchResult( + result: MixedSearchResult, + limit: number, +): { readonly entries: ProjectEntry[]; readonly truncated: boolean } { + const entries: ProjectEntry[] = []; + for (const item of result.items) { + const entry = toProjectEntry(item); + if (entry) { + entries.push(entry); + } + if (entries.length >= limit) { + break; + } + } + + const rootDirectoryCount = result.items.some( + (item) => item.type === "directory" && item.item.relativePath.length === 0, + ) + ? 1 + : 0; + return { + entries, + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + +function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const entry of entries) { + let parentPath = parentPathOf(entry.path); + while (parentPath) { + if (!entryByPath.has(parentPath)) { + entryByPath.set(parentPath, { path: parentPath, kind: "directory" }); + } + parentPath = parentPathOf(parentPath); + } + } + return [...entryByPath.values()]; +} + +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { + 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, + }); +}); + +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), + }), + Effect.timeoutOrElse({ + duration: WORKSPACE_INDEX_SCAN_TIMEOUT, + 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 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.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 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 }); +}); + +/** + * 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: layer, + idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, + }, +) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e8f208cc6898..554a942d78aa 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -22,6 +22,7 @@ import { type AuthEnvironmentScope, AuthSessionId, CommandId, + type DiscoveredLocalServerList, EventId, type OrchestrationCommand, type GitActionProgressEvent, @@ -33,17 +34,20 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, - ProjectCreateDirectoryError, - ProjectDeletePathError, - ProjectMovePathError, - ProjectListDirectoryError, + type ProjectEntriesFailure, + type ProjectFileFailure, + type ProjectFileOperation, + ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + type FilesystemBrowseFailure, FilesystemBrowseError, + AssetWorkspaceContextNotFoundError, + AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -57,42 +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 { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WebPushNotifier } from "./push/WebPushNotifier.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 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 * 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"; @@ -107,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, { @@ -160,18 +299,13 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], + [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], - [WS_METHODS.projectsListDirectory, AuthOrchestrationReadScope], - [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], - [WS_METHODS.projectsDeletePath, AuthOrchestrationOperateScope], - [WS_METHODS.projectsCreateDirectory, AuthOrchestrationOperateScope], - [WS_METHODS.projectsMovePath, AuthOrchestrationOperateScope], - [WS_METHODS.pushGetStatus, AuthOrchestrationReadScope], - [WS_METHODS.pushSubscribe, AuthOrchestrationReadScope], - [WS_METHODS.pushUnsubscribe, AuthOrchestrationReadScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -194,6 +328,18 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.terminalClose, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], + [WS_METHODS.previewOpen, AuthOrchestrationOperateScope], + [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope], + [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope], + [WS_METHODS.previewClose, AuthOrchestrationOperateScope], + [WS_METHODS.previewList, AuthOrchestrationReadScope], + [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], + [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], + [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], + [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], @@ -239,35 +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 providerRegistry = yield* ProviderRegistry; + 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.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const webPushNotifier = yield* WebPushNotifier; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; + 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.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) => @@ -276,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; @@ -549,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", @@ -683,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; @@ -742,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(); @@ -754,7 +919,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* externalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1044,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", }, @@ -1052,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", }, @@ -1158,39 +1327,28 @@ 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, }), ), ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.projectsWriteFile]: (input) => - 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, - }); - }), - ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsListDirectory]: (input) => + [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( - WS_METHODS.projectsListDirectory, - workspaceEntries.listDirectory(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Directory path must stay within the project root." - : `Failed to list workspace directory: ${cause.detail}`; - return new ProjectListDirectoryError({ message, cause }); - }), + WS_METHODS.projectsListEntries, + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + ...input, + ...projectEntriesFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1198,66 +1356,33 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "File path must stay within the project root." - : `Failed to read workspace file: ${cause.detail}`; - return new ProjectReadFileError({ message, cause }); - }), - ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsDeletePath]: (input) => - observeRpcEffect( - WS_METHODS.projectsDeletePath, - workspaceFileSystem.deletePath(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Path must stay within the project root." - : `Failed to delete path: ${cause.detail}`; - return new ProjectDeletePathError({ message, cause }); - }), - ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsCreateDirectory]: (input) => - observeRpcEffect( - WS_METHODS.projectsCreateDirectory, - workspaceFileSystem.createDirectory(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Directory path must stay within the project root." - : `Failed to create directory: ${cause.detail}`; - return new ProjectCreateDirectoryError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.projectsMovePath]: (input) => + [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( - WS_METHODS.projectsMovePath, - workspaceFileSystem.movePath(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Path must stay within the project root." - : `Failed to move path: ${cause.detail}`; - return new ProjectMovePathError({ message, cause }); - }), + WS_METHODS.projectsWriteFile, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.pushGetStatus]: (_input) => - observeRpcEffect(WS_METHODS.pushGetStatus, webPushNotifier.getStatus(), { - "rpc.aggregate": "push", - }), - [WS_METHODS.pushSubscribe]: (input) => - observeRpcEffect(WS_METHODS.pushSubscribe, webPushNotifier.subscribe(input), { - "rpc.aggregate": "push", - }), - [WS_METHODS.pushUnsubscribe]: (input) => - observeRpcEffect(WS_METHODS.pushUnsubscribe, webPushNotifier.unsubscribe(input), { - "rpc.aggregate": "push", - }), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", @@ -1269,13 +1394,60 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: cause.detail, + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsCreateUrl]: (input) => + observeRpcEffect( + WS_METHODS.assetsCreateUrl, + Effect.gen(function* () { + if (input.resource._tag !== "workspace-file") { + return yield* issueAssetUrl({ resource: input.resource }); + } + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.resource.threadId) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.value.projectId) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }); + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, @@ -1442,6 +1614,80 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.previewOpen]: (input) => + observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewNavigate]: (input) => + observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewRefresh]: (input) => + observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewClose]: (input) => + observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewList]: (input) => + observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewReportStatus]: (input) => + observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { + "rpc.aggregate": "preview", + }), + [WS_METHODS.previewAutomationConnect]: (input) => + observeRpcStreamEffect( + WS_METHODS.previewAutomationConnect, + previewAutomationBroker.connect(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationRespond]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationRespond, + previewAutomationBroker.respond(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationReportOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationReportOwner, + previewAutomationBroker.reportOwner(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.previewAutomationClearOwner]: (input) => + observeRpcEffect( + WS_METHODS.previewAutomationClearOwner, + previewAutomationBroker.clearOwner(input), + { "rpc.aggregate": "preview-automation" }, + ), + [WS_METHODS.subscribePreviewEvents]: (_input) => + observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { + "rpc.aggregate": "preview", + }), + [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) => + observeRpcStream( + WS_METHODS.subscribeDiscoveredLocalServers, + Stream.callback((queue) => + Effect.gen(function* () { + yield* portDiscovery.retain; + const initial = yield* portDiscovery.scan(); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers: initial, + scannedAt: initialScannedAt, + }); + yield* portDiscovery.subscribe((servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { servers, scannedAt }); + }), + ); + }), + ), + { "rpc.aggregate": "preview" }, + ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, @@ -1465,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, @@ -1554,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, @@ -1565,9 +1813,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session).pipe( Layer.provideMerge(RpcSerialization.layerJson), + 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/server/vite.config.ts b/apps/server/vite.config.ts index 7e88ac7756a5..473df069ed7d 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -49,6 +49,15 @@ export default mergeConfig( __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "", + ), }, }, test: { diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000000..c9a675ef41c9 --- /dev/null +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## vscode-icons + +The custom file icon symbols in `src/pierre-icons.ts` are adapted from the +[`vscode-icons`](https://github.com/vscode-icons/vscode-icons) project. + +Copyright (c) 2016 Roberto Huertas + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/package.json b/apps/web/package.json index e771109331fa..632e2d14395d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,16 +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.13.0", - "@clerk/react": "^6.7.2", - "@codemirror/language": "^6.12.3", - "@codemirror/language-data": "^6.5.2", + "@clerk/electron": "catalog:", + "@clerk/react": "catalog:", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -29,13 +25,12 @@ "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", - "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@uiw/react-codemirror": "^4.25.10", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -66,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/public/push-sw.js b/apps/web/public/push-sw.js deleted file mode 100644 index 9f1f99d6af85..000000000000 --- a/apps/web/public/push-sw.js +++ /dev/null @@ -1,50 +0,0 @@ -// Service worker for t3code Web Push notifications. -// Receives push messages and focuses/opens the relevant thread on click. - -self.addEventListener("push", (event) => { - let payload = {}; - try { - payload = event.data ? event.data.json() : {}; - } catch { - payload = {}; - } - const title = payload.title || "T3 Code"; - const options = { - body: payload.body || "", - icon: "/favicon-32x32.png", - badge: "/favicon-32x32.png", - data: { url: payload.url || "/" }, - // Same tag replaces an earlier notification for the same thread. - ...(payload.tag ? { tag: payload.tag } : {}), - }; - event.waitUntil(self.registration.showNotification(title, options)); -}); - -self.addEventListener("notificationclick", (event) => { - event.notification.close(); - const targetUrl = (event.notification.data && event.notification.data.url) || "/"; - event.waitUntil( - (async () => { - const clientList = await self.clients.matchAll({ - type: "window", - includeUncontrolled: true, - }); - for (const client of clientList) { - if ("focus" in client) { - await client.focus(); - if ("navigate" in client && targetUrl) { - try { - await client.navigate(targetUrl); - } catch { - // Cross-origin or detached client; ignore. - } - } - return; - } - } - if (self.clients.openWindow) { - await self.clients.openWindow(targetUrl); - } - })(), - ); -}); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx new file mode 100644 index 000000000000..9112e31cb865 --- /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 000000000000..1ecb9f6b7b63 --- /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 000000000000..e4634f5b98db --- /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 new file mode 100644 index 000000000000..673b093e333e --- /dev/null +++ b/apps/web/src/assets/assetUrls.ts @@ -0,0 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { assetEnvironment } from "~/state/assets"; +import { usePreparedConnection } from "~/state/session"; + +export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; + +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; + } + return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); +} + +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 6815cd70f8c4..ced16c15f4ec 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 000000000000..b87276f1b9cf --- /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 d9b69bce94ae..4aa969c0279d 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 5c1309ca06b0..7fc57cf0d03f 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/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx new file mode 100644 index 000000000000..90769f8fb69e --- /dev/null +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +export function BrowserSurfaceSlot(props: { + readonly tabId: string; + readonly visible: boolean; + readonly className?: string; +}) { + const { tabId, visible, className } = props; + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + useBrowserSurfaceStore.getState().present( + tabId, + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + visible && rect.width > 0 && rect.height > 0, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + useBrowserSurfaceStore.getState().hide(tabId); + }; + }, [tabId, visible]); + + return
; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx new file mode 100644 index 000000000000..205dce735832 --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useEffect, useMemo } from "react"; + +import { isElectron } from "~/env"; +import { useTheme } from "~/hooks/useTheme"; +import { useActivePreviewSessions } from "~/previewStateStore"; + +import { readPreviewAnnotationTheme } from "./annotationTheme"; +import { useBrowserPointerStore } from "./browserPointerStore"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +export function ElectronBrowserHost() { + const { resolvedTheme } = useTheme(); + const previewByThreadKey = useActivePreviewSessions(); + const sessions = useMemo( + () => + Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef + ? Object.values(previewState.sessions).map((snapshot) => ({ + threadRef, + snapshot, + active: previewState.activeTabId === snapshot.tabId, + })) + : []; + }), + [previewByThreadKey], + ); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; + }); + }; + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; + }, [resolvedTheme]); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onPointerEvent((event) => { + useBrowserPointerStore.getState().apply(event); + }); + }, []); + + if (!isElectron) return null; + return ( +
+ {sessions.map(({ threadRef, snapshot }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx new file mode 100644 index 000000000000..cdd33fa150dd --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -0,0 +1,127 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; +import { useCallback, useEffect, useRef } from "react"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; + +import { useActiveBrowserRecordingTabId } from "./browserRecording"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; +import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + preload?: string; + webpreferences?: string; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +export function HostedBrowserWebview(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly initialUrl: string | null; +}) { + 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 = useActiveBrowserRecordingTabId() === tabId; + + usePreviewBridge({ threadRef, 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; + if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); + }, []); + + useEffect(() => { + const webview = webviewRef.current; + const bridge = previewBridge; + if (!webview || !config || !bridge) return; + let disposed = false; + const register = () => { + 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. + } + })(); + }; + webview.addEventListener("did-attach", register); + webview.addEventListener("dom-ready", register); + register(); + return () => { + disposed = true; + webview.removeEventListener("did-attach", register); + webview.removeEventListener("dom-ready", register); + }; + }, [config, tabId]); + + if (!config) return null; + const active = presentation?.visible === true && presentation.rect !== null; + const lastRect = presentation?.rect; + const style = + active && lastRect + ? { + left: lastRect.x, + top: lastRect.y, + width: lastRect.width, + height: lastRect.height, + zIndex: 30, + pointerEvents: "auto" as const, + } + : { + left: 0, + top: 0, + width: lastRect?.width ?? 1280, + height: lastRect?.height ?? 800, + zIndex: recording ? 0 : -1, + pointerEvents: "none" as const, + }; + + return ( + + ); +} diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts new file mode 100644 index 000000000000..e12c667d23d7 --- /dev/null +++ b/apps/web/src/browser/annotationTheme.ts @@ -0,0 +1,28 @@ +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; + +const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => + styles.getPropertyValue(name).trim() || fallback; + +export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { + const root = document.documentElement; + const styles = getComputedStyle(root); + return { + colorScheme: root.classList.contains("dark") ? "dark" : "light", + radius: readVariable(styles, "--radius", "0.625rem"), + background: readVariable(styles, "--background", "white"), + foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + popover: readVariable(styles, "--popover", "white"), + popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), + primaryForeground: readVariable(styles, "--primary-foreground", "white"), + muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), + mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), + accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), + fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), + fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), + }; +} diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts new file mode 100644 index 000000000000..de9c173dc2da --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { useBrowserPointerStore } from "./browserPointerStore"; + +beforeEach(() => { + useBrowserPointerStore.setState({ byTabId: {} }); +}); + +describe("browserPointerStore", () => { + it("tracks the latest pointer target independently for each tab", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + store.apply({ + tabId: "tab_a", + phase: "click", + x: 60, + y: 70, + sequence: 2, + createdAt: "2026-06-12T00:00:02.000Z", + }); + + expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ + tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, + tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, + }); + }); + + it("clears one tab without affecting the others", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + + store.clear("tab_a"); + + expect(useBrowserPointerStore.getState().byTabId).toEqual({ + tab_b: expect.objectContaining({ x: 40, y: 50 }), + }); + }); +}); diff --git a/apps/web/src/browser/browserPointerStore.ts b/apps/web/src/browser/browserPointerStore.ts new file mode 100644 index 000000000000..f9f905ddc8f8 --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.ts @@ -0,0 +1,25 @@ +import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface BrowserPointerStoreState { + readonly byTabId: Record; + readonly apply: (event: DesktopPreviewPointerEvent) => void; + readonly clear: (tabId: string) => void; +} + +export const useBrowserPointerStore = create()((set) => ({ + byTabId: {}, + apply: (event) => + set((state) => ({ + byTabId: { + ...state.byTabId, + [event.tabId]: event, + }, + })), + clear: (tabId) => + set((state) => { + if (!(tabId in state.byTabId)) return state; + const { [tabId]: _removed, ...byTabId } = state.byTabId; + return { byTabId }; + }), +})); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts new file mode 100644 index 000000000000..5bb3364807d7 --- /dev/null +++ b/apps/web/src/browser/browserRecording.ts @@ -0,0 +1,298 @@ +import type { + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, +} from "@t3tools/contracts"; +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; + readonly context: CanvasRenderingContext2D; + readonly recorder: MediaRecorder; + readonly chunks: Blob[]; + readonly mimeType: string; + readonly startedAt: string; +} + +const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("preview:active-browser-recording-tab"), +); + +export function useActiveBrowserRecordingTabId(): string | null { + return useAtomValue(activeBrowserRecordingTabIdAtom); +} + +let active: ActiveRecording | null = null; +let unsubscribeFrames: (() => void) | null = null; + +const preferredMimeType = (): string => { + const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; + return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; +}; + +const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { + const recording = active; + if (!recording || recording.tabId !== frame.tabId) return; + const image = new Image(); + image.addEventListener( + "load", + () => { + if (active !== recording) return; + recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + }, + { once: true }, + ); + image.src = `data:image/jpeg;base64,${frame.data}`; +}; + +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) 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 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); + }); + 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); + } 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( + tabId: string, +): Promise { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return null; + 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/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts new file mode 100644 index 000000000000..64fd8e2df2b1 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; + +export interface BrowserSurfaceRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface BrowserSurfacePresentation { + readonly rect: BrowserSurfaceRect | null; + readonly visible: boolean; + readonly updatedAt: number; +} + +interface BrowserSurfaceStoreState { + readonly byTabId: Record; + readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly hide: (tabId: string) => void; +} + +const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => + left !== null && + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height; + +export const useBrowserSurfaceStore = create()((set) => ({ + byTabId: {}, + present: (tabId, rect, visible) => + set((state) => { + const current = state.byTabId[tabId]; + if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { rect, visible, updatedAt: Date.now() }, + }, + }; + }), + hide: (tabId) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current || !current.visible) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, visible: false, updatedAt: Date.now() }, + }, + }; + }), +})); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts new file mode 100644 index 000000000000..d3c7f6a8daba --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -0,0 +1,81 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const readPreparedConnection = vi.fn(); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +describe("browser target resolver", () => { + beforeEach(() => readPreparedConnection.mockReset()); + + it("maps environment ports onto a private network host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/dashboard", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard", + resolvedUrl: "http://192.168.1.25:5173/dashboard", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("refuses public relay hosts until the authenticated gateway exists", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }), + ).toThrow(/authenticated preview gateway/); + }); + + it("normalizes schemeless localhost server-picker values", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( + "http://localhost:5173/", + ); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), + ).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( + "https://example.com/app", + ); + }); + + it("supports private IPv6 environment hosts", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/app?mode=test", + }).resolvedUrl, + ).toBe("http://[::1]:5173/app?mode=test"); + }); + + it("leaves malformed input for the normal navigation error path", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); + }); +}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts new file mode 100644 index 000000000000..9142cce1e720 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -0,0 +1,92 @@ +import type { + BrowserNavigationTarget, + EnvironmentId, + PreviewUrlResolution, +} from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; + +import { readPreparedConnection } from "~/state/session"; + +const isPrivateNetworkHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + return true; + } + if (normalized.endsWith(".ts.net")) return true; + const parts = normalized.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return ( + parts[0] === 10 || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) + ); +}; + +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, +): PreviewUrlResolution { + if (target.kind === "url") { + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + 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.", + ); + } + const protocol = target.protocol ?? "http"; + const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; + const requestedUrl = `${protocol}://localhost:${target.port}${path}`; + const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); + const resolvedHost = normalizedEnvironmentHost.includes(":") + ? `[${normalizedEnvironmentHost}]` + : normalizedEnvironmentHost; + const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + return { + requestedUrl, + resolvedUrl: resolved.toString(), + resolutionKind: + normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" + ? "direct" + : "direct-private-network", + environmentId, + }; +} + +export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { + try { + 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", + port, + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }).resolvedUrl; + } catch { + return rawUrl; + } +} diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts new file mode 100644 index 000000000000..1e3b1632bcc1 --- /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 new file mode 100644 index 000000000000..d621f6dc30c2 --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -0,0 +1,44 @@ +import { previewBridge } from "~/components/preview/previewBridge"; + +interface DesktopTabLease { + references: number; + closeTimer: number | null; + ready: Promise; +} + +const leases = new Map(); + +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); + + 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 new file mode 100644 index 000000000000..b89b87c92898 --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.ts @@ -0,0 +1,98 @@ +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 { resolveAssetUrl } from "~/assets/assetUrls"; +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 class BrowserPreviewUnavailableError extends Data.TaggedError( + "BrowserPreviewUnavailableError", +)<{ + readonly message: string; +}> {} + +export type OpenPreviewMutation = (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; +}) => Promise>; + +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(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()) { + 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.")), + ); + } + return openUrlInPreview({ + threadRef: input.threadRef, + url: assetUrl, + openPreview: input.openPreview, + }); +} diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts new file mode 100644 index 000000000000..35eb665eb7e3 --- /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 new file mode 100644 index 000000000000..6f1cf058e38c --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -0,0 +1,76 @@ +import { useAtomValue } from "@effect/atom-react"; +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"; + +const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; +const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; + +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(loadPreviewWebviewConfig(environmentId)).pipe( + Atom.swr({ + staleTime: PREVIEW_CONFIG_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), + Atom.withLabel(`preview:webview-config:${environmentId}`), + ), +); + +export function usePreviewWebviewConfig( + environmentId: EnvironmentId, +): DesktopPreviewWebviewConfig | null { + const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + return Option.getOrNull(AsyncResult.value(result)); +} diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index e2cf84ccc77a..8f849a6e7b39 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 2838f5028810..5c0ba7c6eccf 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 520130518d55..000000000000 --- 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 0e2a328c30ef..000000000000 --- 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 68179f5cf03c..000000000000 --- 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 031094b7a005..000000000000 --- 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 01ff8603e251..000000000000 --- 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 754930d0ceda..75951db1baff 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 79b439f61095..d0994955db17 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 dc09a7fa0432..7e6f2365e50b 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,903 +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", () => + 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 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", - }); - }), - ); - - it.effect("rejects a stored managed connection for another relay origin", () => - 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, + vi.stubGlobal("window", { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, }); - 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", - }); - }), - ); - 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 b13b324a4113..20bf75c7d6dd 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,6 +1,8 @@ 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 * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, @@ -11,37 +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 { 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, @@ -64,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) => @@ -73,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({ @@ -97,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, @@ -155,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; @@ -238,15 +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; -} - export function collectCloudLinkTargets(input: { readonly primary: CloudLinkTarget | null; readonly saved: ReadonlyArray; @@ -282,7 +270,7 @@ export function listManagedCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -291,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, @@ -313,7 +301,7 @@ export function listCloudDevices(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!relayUrl()) { @@ -321,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) => @@ -334,174 +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 -> { - 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, - }; - }); -} - -export function readPrimaryCloudLinkState(): Effect.Effect< - CloudLinkState | null, - CloudEnvironmentLinkError, - HttpClient.HttpClient -> { +export function readPrimaryCloudLinkState(input: { + readonly target: CloudLinkTarget; +}): Effect.Effect { 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) => @@ -511,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) { @@ -630,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({ @@ -663,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, @@ -690,7 +449,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ); yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: target.environmentId, + expectedEnvironmentId: input.target.environmentId, expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); @@ -707,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 000000000000..4cb62271a480 --- /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 000000000000..aa29a59677eb --- /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 b00c445f08d5..2f631214501b 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 f34ad2f9c997..52f9b6496c95 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 a31ee9e16f30..5f29c121dbcd 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 095ca8422816..34fdacd214af 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 bb188d0b110e..d42aa34baa26 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 291f1830ca36..d9d0e5f44cb6 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,16 +1,42 @@ 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; readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; + readonly relayTracing: { + readonly tracesUrl: string | null; + readonly tracesDataset: string | null; + readonly tracesToken: string | null; + }; } function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export function resolveCloudPublicConfig(): CloudPublicConfig { return { clerkPublishableKey: trimNonEmpty( @@ -20,9 +46,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { relayUrl: normalizeSecureRelayUrl( (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", ), + relayTracing: { + tracesUrl: normalizeSecureUrl( + (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "", + ), + tracesDataset: trimNonEmpty( + import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined, + ), + tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined), + }, }; } +export function resolveRelayTracingConfig() { + const { relayTracing } = resolveCloudPublicConfig(); + return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken + ? { + tracesUrl: relayTracing.tracesUrl, + tracesDataset: relayTracing.tracesDataset, + tracesToken: relayTracing.tracesToken, + } + : null; +} + export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); @@ -31,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 8f2a25bc3a04..7bd8d4967e41 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 908890ad1f53..b1b0c6607e35 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 000000000000..8dae5fed3b58 --- /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 04b25529f2f7..000000000000 --- 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 d98f30a1e5c1..0f1a8f9d4297 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 27c5c311c60c..03f24dac8e94 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 72391f714fca..7798f38e43e2 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; @@ -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 ( @@ -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} + + +
@@ -671,6 +721,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 e047392c12d7..000000000000 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ /dev/null @@ -1,684 +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 { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ - openInPreferredEditorMock: vi.fn(async () => "vscode"), - readLocalApiMock: vi.fn(() => ({ - server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { 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, -})); - -import ChatMarkdown from "./ChatMarkdown"; -import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; - -describe("ChatMarkdown", () => { - afterEach(() => { - openInPreferredEditorMock.mockClear(); - readLocalApiMock.mockClear(); - localStorage.clear(); - document.body.innerHTML = ""; - }); - - 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("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("img") != 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("img")).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(" void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -99,10 +121,36 @@ const EMPTY_MARKDOWN_SKILLS: ReadonlyArray( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); +const highlighterPromiseCache = new Map>(); + +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, + ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); +} const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -224,10 +272,31 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw err; + } + // Language not supported by Shiki — fall back to "text" + return getHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} + function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -278,7 +347,9 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { copiedTimerRef.current = null; }, 1200); }) - .catch(() => undefined); + .catch((cause) => { + reportMarkdownActionFailure({ operation: "copy-table", format }, cause); + }); }, []); useEffect( @@ -414,22 +485,17 @@ function MarkdownCodeBlockTitleContent({ language: string; theme: "light" | "dark"; }) { - const [failedIconUrl, setFailedIconUrl] = useState(null); - if (fenceTitle) { return ( <> - + {fenceTitle} ); } const fileName = syntheticFileNameForLanguageId(language); - const iconUrl = hasSpecificVscodeIconForFileName(fileName, theme) - ? getVscodeIconUrlForEntry(fileName, "file", theme) - : null; - if (!iconUrl || failedIconUrl === iconUrl) { + if (!hasSpecificPierreIconForFileName(fileName)) { return {language}; } return ( @@ -439,15 +505,7 @@ function MarkdownCodeBlockTitleContent({ } > - setFailedIconUrl(iconUrl)} - /> + {language} @@ -468,10 +526,11 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(false); + const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); 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; @@ -488,8 +547,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( () => () => { @@ -645,9 +713,14 @@ interface MarkdownFileLinkProps { targetPath: string; iconPath: string; displayPath: string; + workspaceRelativePath: string | null; + line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; + threadRef?: ScopedThreadRef | undefined; + onOpen: (targetPath: string) => Promise>; + onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -919,63 +992,135 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ targetPath, iconPath, displayPath, + workspaceRelativePath, + line, label, copyMarkdown, theme, + threadRef, + onOpen, + onOpenInBrowser, className, }: MarkdownFileLinkProps) { - const handleOpen = useCallback(() => { - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Open in editor is unavailable", - }); + const handleOpenInEditor = useCallback(() => { + 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) { + handleOpenInEditor(); return; } + useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); + }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); - 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]); - - 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.", - }), - ); + const handleOpenInBrowser = useCallback(() => { + 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) => { @@ -985,28 +1130,42 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "open", label: "Open in editor" }, - { 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") { - handleOpen(); - 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, handleOpen, targetPath], + [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], ); return ( @@ -1020,7 +1179,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - handleOpen(); + if (onOpenInBrowser) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); }} onContextMenu={handleContextMenu} > @@ -1049,9 +1212,14 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && + previous.workspaceRelativePath === next.workspaceRelativePath && + previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && + previous.threadRef === next.threadRef && + previous.onOpen === next.onOpen && + previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } @@ -1059,12 +1227,27 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + threadRef, + onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, 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< @@ -1099,13 +1282,89 @@ 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 }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, - li({ node: _node, children, ...props }) { - return
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ; + li({ node, children, ...props }) { + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
  • + {renderSkillInlineMarkdownChildren(children, skills)} +
  • + ); + }, + input({ node: _node, type, checked, disabled: _disabled, ...props }) { + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number( + event.currentTarget.closest("li")?.dataset.taskMarkerOffset, + ); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); }, a({ node, href, children, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; @@ -1114,6 +1373,7 @@ function ChatMarkdown({ const faviconHost = resolveExternalLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const link = ( { + 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 ? ( @@ -1169,9 +1462,20 @@ function ChatMarkdown({ targetPath={fileLinkMeta.targetPath} iconPath={fileLinkMeta.filePath} displayPath={fileLinkMeta.displayPath} + workspaceRelativePath={fileLinkMeta.workspaceRelativePath} + line={fileLinkMeta.line} label={labelParts.join(" · ")} copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} + threadRef={threadRef} + onOpen={openInPreferredEditor} + onOpenInBrowser={ + threadRef && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(fileLinkMeta.filePath) + ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } className={props.className} /> ); @@ -1216,8 +1520,14 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + onTaskListChange, + 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 3c8e624f5af8..000000000000 --- a/apps/web/src/components/ChatView.browser.tsx +++ /dev/null @@ -1,6488 +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 { 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"; -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 { 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 { - 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, - }, - 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"], - }, - }; -} - -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, - previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, - })) - : 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: [], - 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: [], - 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, - }; -} - -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: [], - 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: [], - 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("*/attachments/:attachmentId", () => - HttpResponse.text(ATTACHMENT_SVG, { - headers: { - "Content-Type": "image/svg+xml", - }, - }), - ), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), -); - -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 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}`], - }), - ); - - 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: {}, - }); - }); - - 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, - terminalMinimized: false, - 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, - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - }, - }); - expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).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 toggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - toggle.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", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } 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", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - }, - }); - }, - { 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", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", - }, - }); - }, - { 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("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", - parentPath: "path/to", - }, - ], - 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 13bd175e0c99..43ed895c0db3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,17 +1,9 @@ -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, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, @@ -19,12 +11,63 @@ import { getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + 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, + ...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", () => { @@ -34,13 +77,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, }, ], }); @@ -58,13 +101,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, }, ], }); @@ -73,17 +116,38 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); + + it("treats element contexts as sendable content (no text, no images, no terminals)", () => { + const state = deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 1, + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.expiredTerminalContextCount).toBe(0); + expect(state.hasSendableContent).toBe(true); + }); + + it("does NOT treat zero element contexts as sendable", () => { + expect( + deriveComposerSendState({ + prompt: "", + imageCount: 0, + terminalContexts: [], + elementContextCount: 0, + }).hasSendableContent, + ).toBe(false); + }); }); 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.", @@ -159,411 +223,118 @@ 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", () => { + it("keeps open threads and makes the active thread most recent", () => { 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"), + 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-hidden"), ThreadId.make("thread-active")]); + ).toEqual(["thread-b", "thread-c", "thread-a"]); }); - it("drops mounted threads once their terminal drawer is no longer open", () => { + it("drops closed threads and enforces the hidden mounted cap", () => { + const ids = Array.from( + { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + (_, index) => `thread-${index}`, + ); expect( reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-closed")], - openThreadIds: [], - activeThreadId: ThreadId.make("thread-closed"), + currentThreadIds: ids, + openThreadIds: ids.slice(1), + activeThreadId: null, activeThreadTerminalOpen: false, }), - ).toEqual([]); + ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); }); +}); - it("keeps only the most recently active hidden terminal threads", () => { +describe("reconcileRetainedMountedThreadIds", () => { + it("retains hidden open threads and adds the active open thread", () => { 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, + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-hidden")], + openThreadIds: [ThreadId.make("thread-hidden")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: true, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, }), - ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); + ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); }); - it("moves the active thread to the end so it is treated as most recently used", () => { + it("can retain the active thread as hidden when it is inactive", () => { 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"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, + reconcileRetainedMountedThreadIds({ + currentThreadIds: [ThreadId.make("thread-active")], + openThreadIds: [ThreadId.make("thread-active")], + activeThreadId: ThreadId.make("thread-active"), + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, + retainInactiveActiveThread: true, }), - ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); + ).toEqual([ThreadId.make("thread-active")]); }); - it("defaults to the hidden mounted terminal cap", () => { + it("evicts the oldest hidden threads beyond the configured cap", () => { const currentThreadIds = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, (_, index) => ThreadId.make(`thread-${index + 1}`), ); expect( - reconcileMountedTerminalThreadIds({ + reconcileRetainedMountedThreadIds({ currentThreadIds, openThreadIds: currentThreadIds, activeThreadId: null, - activeThreadTerminalOpen: false, + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, }), - ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS)); }); }); 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: [], -}); - -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, - }, - ]), - ), - 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: [], - }); + 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, @@ -571,45 +342,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: [], - }); + 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, @@ -617,134 +367,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: [], - }); - - 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: [], - }); + 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: [], - }); - 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, @@ -753,43 +412,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: [], - }); - - 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 de69c5730469..36947caae6f2 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, @@ -22,6 +23,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; +export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -29,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, @@ -42,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: [], }; @@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: { activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; +}): string[] { + return reconcileRetainedMountedThreadIds({ + currentThreadIds: input.currentThreadIds, + openThreadIds: input.openThreadIds, + activeThreadId: input.activeThreadId, + activeThreadOpen: input.activeThreadTerminalOpen, + maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); +} + +export function reconcileRetainedMountedThreadIds(input: { + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; + activeThreadOpen: boolean; + maxHiddenThreadCount: number; + retainInactiveActiveThread?: boolean; }): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + (threadId) => + (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && + openThreadIdSet.has(threadId), ); + const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); const nextThreadIds = hiddenThreadIds.length > maxHiddenThreadCount ? hiddenThreadIds.slice(-maxHiddenThreadCount) @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { if ( input.activeThreadId && - input.activeThreadTerminalOpen && + input.activeThreadOpen && !nextThreadIds.includes(input.activeThreadId) ) { nextThreadIds.push(input.activeThreadId); @@ -185,6 +202,12 @@ export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray; + /** + * Optional element-pick attachment count. Element contexts contribute to + * "sendable content" exactly like images and (text-bearing) terminal + * contexts do: a prompt of just element chips is still a valid send. + */ + elementContextCount?: number; }): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; @@ -195,12 +218,16 @@ export function deriveComposerSendState(options: { const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; + const elementContextCount = options.elementContextCount ?? 0; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + trimmedPrompt.length > 0 || + options.imageCount > 0 || + sendableTerminalContexts.length > 0 || + elementContextCount > 0, }; } @@ -248,8 +275,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 = @@ -305,7 +332,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)) { @@ -327,8 +355,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); @@ -352,7 +380,7 @@ export interface LocalDispatchSnapshot { latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; - sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; + sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; } @@ -369,7 +397,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, }; } @@ -406,8 +434,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { return false; } if ( + session?.activeTurnId !== null && session?.activeTurnId !== undefined && - session.activeTurnId !== null && latestTurn?.turnId !== session.activeTurnId ) { return false; @@ -417,7 +445,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 0396359450b5..44429614b446 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,5 @@ import { type ApprovalRequestId, - DEFAULT_CLAUDE_MODEL, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -22,12 +21,16 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -37,16 +40,22 @@ 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 { memo, 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, 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"; -import { readEnvironmentApi } from "../environmentApi"; +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 { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { parseFilesRouteSearch, stripFilesSearchParams } from "../filesRouteSearch"; +import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -72,12 +81,6 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -96,16 +99,36 @@ 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 { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { + selectActiveRightPanel, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + setActivePreviewTab, + useThreadPreviewState, +} from "../previewStateStore"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; +import { subscribePreviewAction } from "./preview/previewActionBus"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +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 { 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"; @@ -114,20 +137,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 { isTerminalFocused } from "../lib/terminalFocus"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - reconnectSavedEnvironment, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -141,16 +160,44 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; +import { + appendElementContextsToPrompt, + type ElementContextDraft, + formatElementContextLabel, +} 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 "../terminalSessionState"; +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"; import { MessagesTimeline } from "./chat/MessagesTimeline"; 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"; @@ -174,19 +221,14 @@ import { 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, @@ -194,14 +236,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", @@ -234,15 +282,17 @@ 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; -function eventTargetElement(target: EventTarget | null): Element | null { - if (target instanceof Element) return target; - if (target instanceof Node) return target.parentElement; - return null; +function eventPathContainsSelector(event: Event, selector: string): boolean { + const path = event.composedPath(); + if (path.length === 0 && event.target) { + path.push(event.target); + } + return path.some((target) => target instanceof Element && target.closest(selector)); } function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { @@ -250,127 +300,13 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; - const target = eventTargetElement(event.target); - if (target?.closest(TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; - if (target?.closest(TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; 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; @@ -421,21 +357,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); }, []); @@ -461,20 +382,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, }; } @@ -524,6 +454,7 @@ interface PersistentThreadTerminalDrawerProps { launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; @@ -537,19 +468,23 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext, focusRequestId, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, 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), ); @@ -557,16 +492,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra environmentId: threadRef.environmentId, threadId, }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + [knownTerminalSessions, panelTerminalIds], + ); const terminalLabelsById = useMemo(() => { const next = new Map(); - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; - }, [knownTerminalSessions]); + }, [drawerTerminalSessions]); const terminalLaunchLocationsById = useMemo(() => { const next = new Map< string, @@ -580,7 +532,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra return next; } - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { const summary = session.state.summary; if (!summary) { continue; @@ -591,21 +543,23 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: worktreePathForLaunch, }), }); } return next; - }, [knownTerminalSessions, launchContext, project]); + }, [drawerTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( - () => knownTerminalSessions.map((session) => session.target.terminalId), - [knownTerminalSessions], + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSetTerminalMinimized = useTerminalUiStateStore((state) => state.setTerminalMinimized); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); @@ -635,7 +589,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), @@ -645,7 +599,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : {}, @@ -666,36 +620,23 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [storeSetTerminalHeight, threadRef], ); - const minimizeTerminal = useCallback(() => { - storeSetTerminalMinimized(threadRef, true); - }, [storeSetTerminalMinimized, threadRef]); - - const restoreTerminal = useCallback(() => { - storeSetTerminalMinimized(threadRef, false); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSetTerminalMinimized, threadRef]); - 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, @@ -705,29 +646,54 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeSplitTerminal, threadId, threadRef, + openTerminal, + ]); + const splitTerminalVertical = useCallback(() => { + if (!cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + runtimeEnv, + serverOrderedTerminalIds, + storeSplitTerminalVertical, + threadId, + threadRef, ]); 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, @@ -737,6 +703,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeNewTerminal, threadId, threadRef, + openTerminal, ]); const activateTerminal = useCallback( @@ -749,31 +716,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( @@ -799,7 +772,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra worktreePath={effectiveWorktreePath} runtimeEnv={runtimeEnv} visible={visible} - minimized={terminalUiState.terminalMinimized} height={terminalUiState.terminalHeight} // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. terminalIds={terminalUiState.terminalIds} @@ -808,10 +780,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalGroupId={terminalUiState.activeTerminalGroupId} focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} + onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} - onMinimize={minimizeTerminal} - onRestore={restoreTerminal} splitShortcutLabel={visible ? splitShortcutLabel : undefined} + splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} keybindings={keybindings} @@ -826,7 +798,176 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); -export default function ChatView(props: ChatViewProps) { +interface PersistentThreadTerminalPanelProps { + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + 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 = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = + launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +function ChatViewContent(props: ChatViewProps) { const { environmentId, threadId, @@ -840,30 +981,60 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => 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 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( @@ -880,6 +1051,13 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const setComposerDraftElementContexts = useComposerDraftStore( + (store) => store.setElementContexts, + ); + const setComposerDraftPreviewAnnotations = useComposerDraftStore( + (store) => store.setPreviewAnnotations, + ); + const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -904,6 +1082,7 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -914,8 +1093,14 @@ export default function ChatView(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( + null, + ); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -925,7 +1110,6 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -943,6 +1127,10 @@ export default function ChatView(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, {}, @@ -967,17 +1155,14 @@ export default function ChatView(props: ChatViewProps) { ), ); 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 serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); + const serverThreadRefs = useThreadRefs(); + const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => @@ -999,13 +1184,12 @@ export default function ChatView(props: ChatViewProps) { 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 @@ -1013,26 +1197,23 @@ export default function ChatView(props: ChatViewProps) { threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: DEFAULT_CLAUDE_MODEL, + 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 filesOpen = useSearch({ - strict: false, - select: (search) => parseFilesRouteSearch(search).files === "1", - }); const activeThreadId = activeThread?.id ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, @@ -1058,54 +1239,87 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + const activeTerminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const session of activeThreadKnownSessions) { + labels.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return labels; + }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeRightPanelKind = useRightPanelStore((state) => + selectActiveRightPanel(state.byThreadKey, activeThreadRef), + ); + const diffOpen = activeRightPanelKind === "diff"; + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + ); + const activeFileSurface = + activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; + const activePreviewState = useThreadPreviewState(activeThreadRef); + const panelTerminalIds = useMemo( + () => + new Set( + rightPanelState.surfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [rightPanelState.surfaces], + ); + const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); + const rightPanelOpen = rightPanelState.isOpen; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const rightPanelMaximized = + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { - if (!activeThreadRef) { - return; - } - if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient( - activeServerOrderedTerminalIds, - terminalUiState.terminalIds, - ) - ) { - return; - } - reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); - }, [ - activeThreadRef, - activeServerOrderedTerminalIds, - reconcileTerminalIds, - terminalUiState.terminalIds, - ]); + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); + + 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({ @@ -1125,82 +1339,74 @@ export default function ChatView(props: ChatViewProps) { 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.workspaceRoot}` + : null; + const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< + ReadonlyMap> + >(() => new Map()); + const pendingFileSurfaceIds = activeProjectKey + ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) + : EMPTY_PENDING_FILE_SURFACE_IDS; + const handleFilePendingChange = useCallback( + (relativePath: string, pending: boolean) => { + if (!activeProjectKey) return; + setPendingFileSurfaceIdsByProject((currentByProject) => { + 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); + const nextByProject = new Map(currentByProject); + if (next.size === 0) nextByProject.delete(activeProjectKey); + else nextByProject.set(activeProjectKey, next); + return nextByProject; + }); + }, + [activeProjectKey], + ); + const configuredPreviewUrls = useMemo( + () => getConfiguredPreviewUrls(activeProject?.scripts), + [activeProject?.scripts], ); useEffect(() => { - if (routeKind !== "server") { - return; - } - return retainThreadDetailSubscription(environmentId, threadId); - }, [environmentId, routeKind, threadId]); + if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; + useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); + }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); // 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", @@ -1208,13 +1414,11 @@ export default function ChatView(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); @@ -1232,14 +1436,7 @@ export default function ChatView(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, @@ -1253,14 +1450,7 @@ export default function ChatView(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( @@ -1370,24 +1560,21 @@ export default function ChatView(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; @@ -1400,17 +1587,11 @@ export default function ChatView(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 @@ -1424,65 +1605,37 @@ export default function ChatView(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: ( <> - - )} - + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
    + +
    + )} + - {/* Input bar */} -
    -
    - -
    - + {/* Input bar */} +
    +
    + +
    + +
    + {isGitRepo && ( + + )}
    - {isGitRepo && ( - { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} /> - )} + ) : null}
    - - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} + {/* end chat column */}
    - {/* end chat column */} - - {/* Plan sidebar */} - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - ( + - ) : null} + ))}
    - {/* end horizontal flex container */} - - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {shouldUsePlanSidebarSheet ? ( - - + {rightPanelContent} + + ) : null} + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + + + layoutControls={panelToggleControls} + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface?.id ?? null} + pendingSurfaceIds={pendingFileSurfaceIds} + previewSessions={activePreviewState.sessions} + terminalLabelsById={activeTerminalLabelsById} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onCloseOtherSurfaces={closeOtherRightPanelSurfaces} + onCloseSurfacesToRight={closeRightPanelSurfacesToRight} + onCloseAllSurfaces={closeAllRightPanelSurfaces} + onCopyFilePath={copyRightPanelFilePath} + onAddBrowser={createBrowserSurface} + onAddTerminal={addTerminalSurface} + onAddDiff={addDiffSurface} + onAddFiles={addFilesSurface} + browserAvailable={isPreviewSupportedInRuntime()} + diffAvailable={isServerThread && isGitRepo} + filesAvailable={activeProject !== null} + > + {rightPanelContent} + ) : null} {expandedImage && ( - + )} ); } + +export default function ChatView(props: ChatViewProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index eb5fec9a91bb..651fe34e4b4c 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: [], ...overrides, }; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 982950be5e5c..ab53adbefb16 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 1e120f97219a..267d21cb050d 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_CLAUDE_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("claudeAgent"), model: DEFAULT_CLAUDE_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/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2fcbd929e178..18579cda6d37 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -64,7 +64,7 @@ import { type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { basenameOfPath } from "~/vscode-icons"; +import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, diff --git a/apps/web/src/components/CreatePrDialog.tsx b/apps/web/src/components/CreatePrDialog.tsx deleted file mode 100644 index d7e3f7dee2c1..000000000000 --- a/apps/web/src/components/CreatePrDialog.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import type { EnvironmentId, VcsRef } from "@t3tools/contracts"; -import { CheckIcon, GitBranchIcon, SearchIcon } from "lucide-react"; -import { useDeferredValue, useEffect, useMemo, useState } from "react"; - -import { useVcsRefs } from "../lib/vcsRefState"; -import { cn } from "../lib/utils"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { ScrollArea } from "./ui/scroll-area"; - -interface CreatePrDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - environmentId: EnvironmentId | null; - cwd: string | null; - /** Branch the pull request is created from. */ - headBranch: string | null; - /** Lowercased terminology, e.g. "pull request" or "merge request". */ - changeRequestLabel: string; - onConfirm: (baseBranch: string) => void; -} - -const EMPTY_REFS: ReadonlyArray = []; - -export function CreatePrDialog({ - open, - onOpenChange, - environmentId, - cwd, - headBranch, - changeRequestLabel, - onConfirm, -}: CreatePrDialogProps) { - const [query, setQuery] = useState(""); - const deferredQuery = useDeferredValue(query.trim()); - const [selectedBase, setSelectedBase] = useState(null); - - const refTarget = useMemo( - () => ({ environmentId, cwd, query: deferredQuery }), - [cwd, deferredQuery, environmentId], - ); - const refState = useVcsRefs(refTarget); - const refs = refState.data?.refs ?? EMPTY_REFS; - - // Candidate base branches: every ref except the branch we're merging from. - // De-duplicate by display name (a branch may exist both locally and on a remote). - const candidates = useMemo(() => { - const seen = new Set(); - const result: VcsRef[] = []; - for (const ref of refs) { - if (ref.name === headBranch || seen.has(ref.name)) continue; - seen.add(ref.name); - result.push(ref); - } - return result; - }, [refs, headBranch]); - - const defaultBase = useMemo( - () => candidates.find((ref) => ref.isDefault)?.name ?? null, - [candidates], - ); - - // Reset when closed; preselect the repo's default branch once it loads. - useEffect(() => { - if (!open) { - setSelectedBase(null); - setQuery(""); - } - }, [open]); - useEffect(() => { - if (open && selectedBase === null && defaultBase !== null) { - setSelectedBase(defaultBase); - } - }, [open, selectedBase, defaultBase]); - - const isLoading = refState.isPending && refState.data === null; - const canConfirm = selectedBase !== null; - - return ( - - - - Create {changeRequestLabel} - - {headBranch - ? `Merge ${headBranch} into the branch you select below.` - : `Select the branch to merge into.`} - - - -
    -
    - -
    - {isLoading ? ( -

    Loading branches...

    - ) : candidates.length === 0 ? ( -

    No branches found.

    - ) : ( - candidates.map((ref) => { - const isSelected = ref.name === selectedBase; - const badge = ref.isDefault ? "default" : ref.isRemote ? "remote" : null; - return ( - - ); - }) - )} -
    -
    -
    - - - - -
    -
    - ); -} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 0f6b76008578..cbcd36ce05e5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,31 +1,29 @@ -import { FileDiff, Virtualizer } from "@pierre/diffs/react"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; -import type { TurnId } from "@t3tools/contracts"; +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 { readLocalApi } from "../localApi"; -import { resolvePathLinkTarget } from "../terminal-links"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -35,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 { RightPanelTabs } from "./RightPanelTabs"; +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], @@ -148,49 +177,58 @@ const DIFF_PANEL_UNSAFE_CSS = ` interface DiffPanelProps { mode?: DiffPanelMode; + composerDraftTarget: ScopedThreadRef | DraftId; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - const navigate = useNavigate(); +export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { 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); @@ -209,8 +247,20 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { [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 @@ -219,6 +269,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); + 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 ?? "?"}` + : selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -229,62 +301,116 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : 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") { @@ -297,243 +423,254 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), ); }, [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 openDiffFileInEditor = useCallback( + const openDiffFile = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + openDiffFilePrimaryAction({ + threadRef: routeThreadRef, + filePath, + activeCwd, + openInEditor: (targetPath) => { + 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], + [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); + }} + > + -
    -
    - - Turn{" "} - {summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"} - - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - + {selectedGitSource.baseRef} + + + +
    +
    +
    - - {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)); }} /> } @@ -574,7 +709,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -613,24 +748,35 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
    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 ? ( + ) : (

    @@ -641,90 +787,79 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {

    ) ) : 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; - openDiffFileInEditor(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}

    {props.header}
    ) : ( -
    -
    {props.header}
    +
    + {props.header}
    )} {props.children} @@ -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 5babd4248ad6..3ec748c6bcb2 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,13 +57,24 @@ 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, }} highlighterOptions={{ theme: diffThemeName, tokenizeMaxLineLength: 1_000, + useTokenTransformer: true, }} > diff --git a/apps/web/src/components/FileBrowserPanel.tsx b/apps/web/src/components/FileBrowserPanel.tsx deleted file mode 100644 index fec6866a21e4..000000000000 --- a/apps/web/src/components/FileBrowserPanel.tsx +++ /dev/null @@ -1,1905 +0,0 @@ -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import type { EnvironmentId, ProjectReadFileResult } from "@t3tools/contracts"; -import { - BookOpenIcon, - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, - CodeIcon, - EllipsisVerticalIcon, - FolderIcon, - FolderInputIcon, - FolderPlusIcon, - Maximize2Icon, - Minimize2Icon, - PencilIcon, - RefreshCwIcon, - SaveIcon, - SquarePenIcon, - Trash2Icon, - UploadIcon, - XIcon, -} from "lucide-react"; -import { - type ChangeEvent as ReactChangeEvent, - type DragEvent as ReactDragEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, - Suspense, - lazy, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; -import * as Schema from "effect/Schema"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { parseFilesRouteSearch } from "../filesRouteSearch"; -import { stripDiffSearchParams } from "../diffRouteSearch"; -import { useTheme } from "../hooks/useTheme"; -import { getHighlighterPromise } from "../lib/codeHighlight"; -import { resolveDiffThemeName } from "../lib/diffRendering"; -import { cn } from "~/lib/utils"; -import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; -import ChatMarkdown from "./ChatMarkdown"; -import { DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { RightPanelTabs } from "./RightPanelTabs"; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "./ui/menu"; -import { Toggle, ToggleGroup } from "./ui/toggle-group"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; - -// CodeMirror and its language modes are heavy; keep them out of the main bundle -// and only load them when the user actually enters edit mode. -const FileEditor = lazy(() => import("./FileEditor")); - -interface FileBrowserPanelProps { - mode?: DiffPanelMode; -} - -// Larger cap for binary previews (images, PDFs) so they aren't truncated; text -// reads use the server default. The server clamps this to its own hard limit. -const PREVIEW_READ_MAX_BYTES = 5 * 1024 * 1024; - -// Above this panel width the tree and viewer sit side by side; below it (mobile, -// tablet, narrow dock) the viewer replaces the tree as a master-detail view. -const SPLIT_MIN_WIDTH = 640; - -const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; - -const TREE_DEFAULT_WIDTH = 256; -const TREE_MIN_WIDTH = 180; -const TREE_MAX_WIDTH = 480; -const TREE_WIDTH_STORAGE_KEY = "t3code_file_browser_tree_width"; - -function clampTreeWidth(width: number): number { - return Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, width)); -} - -const IMAGE_EXTENSIONS = new Set([ - ".avif", - ".bmp", - ".gif", - ".heic", - ".heif", - ".ico", - ".jpeg", - ".jpg", - ".png", - ".svg", - ".tiff", - ".webp", -]); - -const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]); - -const SHIKI_LANGUAGE_ALIASES: Record = { - cjs: "javascript", - cts: "typescript", - gitignore: "ini", - mjs: "javascript", - mts: "typescript", - yml: "yaml", -}; - -interface DirState { - status: "loading" | "loaded" | "error"; - entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; - truncated: boolean; - error?: string; -} - -interface FileState { - status: "idle" | "loading" | "loaded" | "error"; - data?: ProjectReadFileResult; - error?: string; -} - -function basenameOf(path: string): string { - const index = path.lastIndexOf("/"); - return index === -1 ? path : path.slice(index + 1); -} - -function parentDirOf(path: string): string { - const index = path.lastIndexOf("/"); - return index === -1 ? "" : path.slice(0, index); -} - -function extensionOf(path: string): string { - const match = /\.[a-z0-9]+$/i.exec(basenameOf(path)); - return match ? match[0].toLowerCase() : ""; -} - -function isImagePath(path: string): boolean { - return IMAGE_EXTENSIONS.has(extensionOf(path)); -} - -function isPdfPath(path: string): boolean { - return extensionOf(path) === ".pdf"; -} - -function isMarkdownPath(path: string): boolean { - return MARKDOWN_EXTENSIONS.has(extensionOf(path)); -} - -function languageForPath(path: string): string { - const extension = extensionOf(path).slice(1); - if (!extension) { - return "text"; - } - return SHIKI_LANGUAGE_ALIASES[extension] ?? extension; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function dragHasFiles(event: ReactDragEvent): boolean { - return Array.from(event.dataTransfer?.types ?? []).includes("Files"); -} - -function readFileAsBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - if (typeof result !== "string") { - reject(new Error("Failed to read file.")); - return; - } - // readAsDataURL yields "data:;base64," — keep only the payload. - const commaIndex = result.indexOf(","); - resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result); - }; - reader.onerror = () => reject(reader.error ?? new Error("Failed to read file.")); - reader.readAsDataURL(file); - }); -} - -function messageOfError(error: unknown): string { - if ( - error && - typeof error === "object" && - "message" in error && - typeof (error as { message: unknown }).message === "string" - ) { - return (error as { message: string }).message; - } - return "Something went wrong."; -} - -function HighlightedCode(props: { code: string; language: string; themeName: string }) { - const { code, language, themeName } = props; - const [html, setHtml] = useState(null); - - useEffect(() => { - let cancelled = false; - setHtml(null); - getHighlighterPromise(language) - .then((highlighter) => { - if (cancelled) { - return; - } - try { - setHtml(highlighter.codeToHtml(code, { lang: language, theme: themeName })); - } catch { - try { - setHtml(highlighter.codeToHtml(code, { lang: "text", theme: themeName })); - } catch { - setHtml(null); - } - } - }) - .catch(() => { - if (!cancelled) { - setHtml(null); - } - }); - return () => { - cancelled = true; - }; - }, [code, language, themeName]); - - if (html === null) { - return ( -
    -        {code}
    -      
    - ); - } - - return ( -
    - ); -} - -const ACTION_BUTTON_CLASS = - "inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground transition-colors hover:border-border hover:text-foreground"; - -const ROW_ACTION_BUTTON_CLASS = - "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 transition-colors hover:bg-background/70 hover:text-foreground"; - -export default function FileBrowserPanel({ mode = "inline" }: FileBrowserPanelProps) { - const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); - const themeName = resolveDiffThemeName(resolvedTheme); - - const threadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const filesSearch = useSearch({ - strict: false, - select: (search) => parseFilesRouteSearch(search), - }); - const filePath = filesSearch.filePath ?? null; - const isMaximized = filesSearch.filesFull === "1"; - - const activeThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => - activeThread && activeProjectId - ? selectProjectByRef(store, { - environmentId: activeThread.environmentId, - projectId: activeProjectId, - }) - : undefined, - ); - const environmentId = activeThread?.environmentId ?? null; - const cwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; - const sessionKey = `${environmentId ?? ""}::${cwd ?? ""}`; - - const [dirStates, setDirStates] = useState>(() => new Map()); - const [expanded, setExpanded] = useState>(() => new Set()); - const [fileState, setFileState] = useState({ status: "idle" }); - const [markdownView, setMarkdownView] = useState<"rendered" | "source">("rendered"); - const [editing, setEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const [editSaving, setEditSaving] = useState(false); - const [editError, setEditError] = useState(null); - - // Default each markdown file to the rendered view, and abandon any in-progress - // edit, whenever a different file is opened. - useEffect(() => { - setMarkdownView("rendered"); - setEditing(false); - setEditError(null); - }, [filePath]); - - // Switch between split (tree + viewer) and master-detail based on panel width. - const bodyRef = useRef(null); - const [isSplit, setIsSplit] = useState(false); - useEffect(() => { - const element = bodyRef.current; - if (!element) { - return; - } - const observer = new ResizeObserver((entries) => { - const width = entries[0]?.contentRect.width ?? 0; - setIsSplit(width >= SPLIT_MIN_WIDTH); - }); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - const [treeWidth, setTreeWidth] = useState(() => { - const stored = getLocalStorageItem(TREE_WIDTH_STORAGE_KEY, Schema.Finite); - return stored == null ? TREE_DEFAULT_WIDTH : clampTreeWidth(stored); - }); - const startTreeResize = useCallback( - (event: ReactPointerEvent) => { - event.preventDefault(); - const startX = event.clientX; - const startWidth = treeWidth; - let latest = startWidth; - const onMove = (moveEvent: PointerEvent) => { - latest = clampTreeWidth(startWidth + (moveEvent.clientX - startX)); - setTreeWidth(latest); - }; - const onUp = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onUp); - document.body.style.removeProperty("user-select"); - document.body.style.removeProperty("cursor"); - setLocalStorageItem(TREE_WIDTH_STORAGE_KEY, latest, Schema.Finite); - }; - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - document.addEventListener("pointermove", onMove); - document.addEventListener("pointerup", onUp); - }, - [treeWidth], - ); - - const dirStatesRef = useRef(dirStates); - useEffect(() => { - dirStatesRef.current = dirStates; - }, [dirStates]); - - const sessionKeyRef = useRef(sessionKey); - - const loadDir = useCallback( - (dirPath: string) => { - const key = sessionKey; - const setDir = (state: DirState) => - setDirStates((previous) => new Map(previous).set(dirPath, state)); - - if (!cwd || !environmentId) { - setDir({ status: "error", entries: [], truncated: false, error: "No active project." }); - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setDir({ status: "error", entries: [], truncated: false, error: "Not connected." }); - return; - } - - setDir({ status: "loading", entries: [], truncated: false }); - projects - .listDirectory({ cwd, ...(dirPath ? { relativePath: dirPath } : {}) }) - .then((result) => { - if (sessionKeyRef.current !== key) { - return; - } - setDir({ - status: "loaded", - entries: result.entries.map((entry) => ({ path: entry.path, kind: entry.kind })), - truncated: result.truncated, - }); - }) - .catch((error: unknown) => { - if (sessionKeyRef.current !== key) { - return; - } - setDir({ - status: "error", - entries: [], - truncated: false, - error: messageOfError(error), - }); - }); - }, - [cwd, environmentId, sessionKey], - ); - - // Reset and reload the tree whenever the active workspace changes. - useEffect(() => { - sessionKeyRef.current = sessionKey; - setDirStates(new Map()); - setExpanded(new Set()); - if (cwd && environmentId) { - loadDir(""); - } - }, [sessionKey, cwd, environmentId, loadDir]); - - // Recover the root listing once a (re)connection becomes available. - useEffect(() => { - return subscribeEnvironmentConnections(() => { - if (!environmentId || !cwd) { - return; - } - const rootState = dirStatesRef.current.get(""); - if ( - (!rootState || rootState.status === "error") && - readEnvironmentConnection(environmentId)?.client.projects - ) { - loadDir(""); - } - }); - }, [environmentId, cwd, loadDir]); - - // Fetch the selected file's contents. The reload token lets the refresh - // button re-read the file (e.g. after the agent edits it on disk). - const [fileReloadToken, setFileReloadToken] = useState(0); - useEffect(() => { - if (!filePath || !cwd || !environmentId) { - setFileState({ status: "idle" }); - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setFileState({ status: "error", error: "Not connected." }); - return; - } - let cancelled = false; - setFileState({ status: "loading" }); - const maxBytes = - isImagePath(filePath) || isPdfPath(filePath) ? PREVIEW_READ_MAX_BYTES : undefined; - projects - .readFile({ cwd, relativePath: filePath, ...(maxBytes ? { maxBytes } : {}) }) - .then((result) => { - if (!cancelled) { - setFileState({ status: "loaded", data: result }); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - setFileState({ status: "error", error: messageOfError(error) }); - } - }); - return () => { - cancelled = true; - }; - }, [filePath, cwd, environmentId, fileReloadToken]); - const refreshFile = useCallback(() => setFileReloadToken((token) => token + 1), []); - - const fileData = fileState.status === "loaded" ? fileState.data : undefined; - // Editing is limited to fully-loaded UTF-8 text. Truncated reads are excluded - // because saving the partial contents would destroy the rest of the file. - const canEdit = Boolean( - filePath && fileData && fileData.encoding === "utf8" && !fileData.truncated, - ); - const editDirty = editing && fileData ? editValue !== fileData.contents : false; - - const beginEdit = useCallback(() => { - if (!fileData || fileData.encoding !== "utf8") { - return; - } - setEditValue(fileData.contents); - setEditError(null); - setEditing(true); - }, [fileData]); - - const cancelEdit = useCallback(() => { - setEditing(false); - setEditError(null); - }, []); - - const saveEdit = useCallback(async () => { - if (!filePath || !cwd || !environmentId) { - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setEditError("Not connected."); - return; - } - setEditSaving(true); - setEditError(null); - try { - await projects.writeFile({ - cwd, - relativePath: filePath, - contents: editValue, - encoding: "utf8", - }); - setEditing(false); - // Re-read so the viewer reflects the saved contents and updated byte size. - refreshFile(); - } catch (error) { - setEditError(messageOfError(error)); - } finally { - setEditSaving(false); - } - }, [filePath, cwd, environmentId, editValue, refreshFile]); - - const toggleDir = useCallback( - (dirPath: string) => { - setExpanded((previous) => { - const next = new Set(previous); - if (next.has(dirPath)) { - next.delete(dirPath); - } else { - next.add(dirPath); - } - return next; - }); - if (!dirStatesRef.current.has(dirPath)) { - loadDir(dirPath); - } - }, - [loadDir], - ); - - const closePanel = useCallback(() => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: { diff: undefined, files: undefined }, - }); - }, [navigate, threadRef]); - - const toggleMaximize = useCallback(() => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - replace: true, - search: (previous) => ({ - ...previous, - files: "1", - filesFull: isMaximized ? undefined : "1", - }), - }); - }, [isMaximized, navigate, threadRef]); - - // Allow Escape to leave full-screen mode (matches the sheet/overlay convention). - useEffect(() => { - if (!isMaximized) { - return; - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - toggleMaximize(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [isMaximized, toggleMaximize]); - - const selectFile = useCallback( - (path: string) => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), files: "1", filePath: path }), - }); - }, - [navigate, threadRef], - ); - - const clearFile = useCallback(() => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - replace: true, - search: (previous) => { - const { filePath: _filePath, ...rest } = previous; - return { ...rest, files: "1" }; - }, - }); - }, [navigate, threadRef]); - - // Upload: write dropped/picked files into a target directory via writeFile. - const fileInputRef = useRef(null); - const uploadTargetRef = useRef(""); - const [uploadState, setUploadState] = useState<{ - status: "idle" | "uploading" | "error"; - message?: string; - }>({ status: "idle" }); - const [dropTargetDir, setDropTargetDir] = useState(null); - - const uploadFiles = useCallback( - async (targetDir: string, fileList: FileList | File[]) => { - if (!cwd || !environmentId) { - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setUploadState({ status: "error", message: "Not connected." }); - return; - } - const files = Array.from(fileList); - if (files.length === 0) { - return; - } - setUploadState({ status: "uploading" }); - try { - for (const file of files) { - if (file.size > UPLOAD_MAX_BYTES) { - throw new Error( - `${file.name} exceeds the ${formatBytes(UPLOAD_MAX_BYTES)} upload limit.`, - ); - } - const base64 = await readFileAsBase64(file); - const relativePath = targetDir ? `${targetDir}/${file.name}` : file.name; - await projects.writeFile({ cwd, relativePath, contents: base64, encoding: "base64" }); - } - setUploadState({ status: "idle" }); - } catch (error) { - setUploadState({ status: "error", message: messageOfError(error) }); - } - loadDir(targetDir); - if (targetDir) { - setExpanded((previous) => new Set(previous).add(targetDir)); - } - }, - [cwd, environmentId, loadDir], - ); - - const openUploadPicker = useCallback((targetDir: string) => { - uploadTargetRef.current = targetDir; - fileInputRef.current?.click(); - }, []); - - const onUploadInputChange = useCallback( - (event: ReactChangeEvent) => { - const files = event.target.files; - if (files && files.length > 0) { - void uploadFiles(uploadTargetRef.current, files); - } - event.target.value = ""; - }, - [uploadFiles], - ); - - const onDirDragOver = useCallback( - (targetDir: string) => (event: ReactDragEvent) => { - if (!dragHasFiles(event)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "copy"; - } - setDropTargetDir(targetDir); - }, - [], - ); - - const onDirDrop = useCallback( - (targetDir: string) => (event: ReactDragEvent) => { - if (!dragHasFiles(event)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - setDropTargetDir(null); - const files = event.dataTransfer?.files; - if (files && files.length > 0) { - void uploadFiles(targetDir, files); - } - }, - [uploadFiles], - ); - - const onDropZoneDragLeave = useCallback( - (targetDir: string) => (event: ReactDragEvent) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setDropTargetDir((current) => (current === targetDir ? null : current)); - } - }, - [], - ); - - const createFolderFormId = useId(); - const [createFolderState, setCreateFolderState] = useState<{ - open: boolean; - parentDir: string; - }>({ open: false, parentDir: "" }); - const [newFolderName, setNewFolderName] = useState(""); - const [deleteTarget, setDeleteTarget] = useState<{ - path: string; - kind: "file" | "directory"; - } | null>(null); - const [actionError, setActionError] = useState(null); - - const openCreateFolder = useCallback((parentDir: string) => { - setNewFolderName(""); - setActionError(null); - setCreateFolderState({ open: true, parentDir }); - }, []); - - const submitCreateFolder = useCallback(async () => { - const name = newFolderName.trim(); - if (!name || !cwd || !environmentId) { - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setActionError("Not connected."); - return; - } - const parentDir = createFolderState.parentDir; - const relativePath = parentDir ? `${parentDir}/${name}` : name; - try { - await projects.createDirectory({ cwd, relativePath }); - setCreateFolderState({ open: false, parentDir: "" }); - loadDir(parentDir); - if (parentDir) { - setExpanded((previous) => new Set(previous).add(parentDir)); - } - } catch (error) { - setActionError(messageOfError(error)); - } - }, [newFolderName, cwd, environmentId, createFolderState.parentDir, loadDir]); - - const requestDelete = useCallback((path: string, kind: "file" | "directory") => { - setActionError(null); - setDeleteTarget({ path, kind }); - }, []); - - const moveFormId = useId(); - const [moveState, setMoveState] = useState<{ - open: boolean; - mode: "rename" | "move"; - sourcePath: string; - sourceKind: "file" | "directory"; - value: string; - }>({ open: false, mode: "rename", sourcePath: "", sourceKind: "file", value: "" }); - - const openRename = useCallback((path: string, kind: "file" | "directory") => { - setActionError(null); - setMoveState({ - open: true, - mode: "rename", - sourcePath: path, - sourceKind: kind, - value: basenameOf(path), - }); - }, []); - - const openMove = useCallback((path: string, kind: "file" | "directory") => { - setActionError(null); - setMoveState({ - open: true, - mode: "move", - sourcePath: path, - sourceKind: kind, - value: parentDirOf(path), - }); - }, []); - - const submitMove = useCallback(async () => { - if (!cwd || !environmentId) { - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setActionError("Not connected."); - return; - } - const { mode, sourcePath, sourceKind, value } = moveState; - let toPath: string; - if (mode === "rename") { - const name = value.trim().replace(/\/+$/, ""); - if (!name) { - return; - } - const parentDir = parentDirOf(sourcePath); - toPath = parentDir ? `${parentDir}/${name}` : name; - } else { - const destDir = value.trim().replace(/^\/+|\/+$/g, ""); - toPath = destDir ? `${destDir}/${basenameOf(sourcePath)}` : basenameOf(sourcePath); - } - if (toPath === sourcePath) { - setMoveState((previous) => ({ ...previous, open: false })); - return; - } - try { - await projects.movePath({ cwd, fromRelativePath: sourcePath, toRelativePath: toPath }); - setMoveState((previous) => ({ ...previous, open: false })); - const sourceParent = parentDirOf(sourcePath); - const destParent = parentDirOf(toPath); - loadDir(sourceParent); - if (destParent !== sourceParent) { - loadDir(destParent); - } - setExpanded((previous) => { - const next = new Set(previous); - next.delete(sourcePath); - if (destParent) { - next.add(destParent); - } - return next; - }); - // Keep the viewer pointed at the file if it (or its parent) was moved. - if (filePath === sourcePath) { - selectFile(toPath); - } else if (sourceKind === "directory" && filePath?.startsWith(`${sourcePath}/`)) { - selectFile(`${toPath}${filePath.slice(sourcePath.length)}`); - } - } catch (error) { - setActionError(messageOfError(error)); - } - }, [cwd, environmentId, moveState, loadDir, filePath, selectFile]); - - const confirmDelete = useCallback(async () => { - if (!deleteTarget || !cwd || !environmentId) { - return; - } - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setActionError("Not connected."); - return; - } - const target = deleteTarget; - try { - await projects.deletePath({ cwd, relativePath: target.path }); - setDeleteTarget(null); - loadDir(parentDirOf(target.path)); - setExpanded((previous) => { - const next = new Set(previous); - next.delete(target.path); - return next; - }); - if ( - filePath === target.path || - (target.kind === "directory" && filePath?.startsWith(`${target.path}/`)) - ) { - clearFile(); - } - } catch (error) { - setActionError(messageOfError(error)); - } - }, [deleteTarget, cwd, environmentId, loadDir, filePath, clearFile]); - - const renderDir = useCallback( - (dirPath: string, depth: number): ReactNode => { - const state = dirStates.get(dirPath); - const indent = 8 + depth * 12; - - if (!state || state.status === "loading") { - return ( -
    - Loading… -
    - ); - } - if (state.status === "error") { - return ( -
    - {state.error ?? "Failed to load."} - -
    - ); - } - if (state.entries.length === 0) { - return ( -
    - Empty -
    - ); - } - - return ( - <> - {state.entries.map((entry) => { - const isDirectory = entry.kind === "directory"; - const isExpanded = expanded.has(entry.path); - const isSelected = entry.path === filePath; - return ( -
    -
    - -
    - - - } - > - - - - {isDirectory ? ( - openCreateFolder(entry.path)}> - - New folder - - ) : null} - openRename(entry.path, entry.kind)}> - - Rename - - openMove(entry.path, entry.kind)}> - - Move - - - requestDelete(entry.path, entry.kind)} - > - - Delete - - - -
    -
    - {isDirectory && isExpanded ? renderDir(entry.path, depth + 1) : null} -
    - ); - })} - {state.truncated ? ( -
    - Listing truncated… -
    - ) : null} - - ); - }, - [ - dirStates, - expanded, - filePath, - loadDir, - resolvedTheme, - selectFile, - toggleDir, - dropTargetDir, - onDirDragOver, - onDirDrop, - onDropZoneDragLeave, - openCreateFolder, - openRename, - openMove, - requestDelete, - ], - ); - - const treeVisible = isSplit || !filePath; - - const headerRow = ( - <> -
    - - - {filePath ? basenameOf(filePath) : (activeProject?.name ?? "Files")} - -
    -
    - {treeVisible ? ( - <> - - openUploadPicker("")} - > - - - } - /> - Upload to project root - - - openCreateFolder("")} - > - - - } - /> - New folder in project root - - - loadDir("")} - > - - - } - /> - Refresh - - - ) : null} - - - {isMaximized ? ( - - ) : ( - - )} - - } - /> - - {isMaximized ? "Exit full screen" : "Full screen"} - - - - - - - } - /> - Close panel - -
    - - ); - - const markdownToggle = - filePath && - isMarkdownPath(filePath) && - fileState.status === "loaded" && - fileState.data?.encoding === "utf8" ? ( - { - const next = value[0]; - if (next === "rendered" || next === "source") { - setMarkdownView(next); - } - }} - > - - - - - - - - ) : null; - - const renderFilePathBar = (showBack: boolean) => - filePath ? ( -
    - {showBack && !editing ? ( - - ) : null} - - {filePath} - {editDirty ? : null} - - {editing ? ( - <> - - - - ) : ( - <> - {markdownToggle} - {canEdit ? ( - - - - - } - /> - Edit file - - ) : null} - - - - - } - /> - Refresh file - - - - } - > - - - - openRename(filePath, "file")}> - - Rename - - openMove(filePath, "file")}> - - Move - - - requestDelete(filePath, "file")}> - - Delete - - - - - )} -
    - ) : null; - - const viewer = filePath ? ( - - ) : null; - - const editorPane = - filePath && editing ? ( -
    - {editError ? ( -
    - {editError} -
    - ) : null} -
    - - Loading editor… -
    - } - > - - -
    -
    - ) : null; - - // Show the editor in place of the read-only viewer while editing. - const content = editing ? editorPane : viewer; - - const uploadBanner = - uploadState.status === "idle" ? null : ( -
    - {uploadState.status === "uploading" ? "Uploading…" : uploadState.message} -
    - ); - - // Tree scroll area doubles as a drop zone targeting the workspace root. - const treeScroll = ( -
    - {renderDir("", 0)} -
    - ); - - let body: ReactNode; - if (!activeThread || !cwd) { - body = ( -
    - Select a thread with a project to browse its files. -
    - ); - } else if (isSplit) { - // Wide layout: tree and viewer side by side. - body = ( -
    -
    - {uploadBanner} - {treeScroll} -
    -
    -
    - {filePath ? ( - <> - {renderFilePathBar(false)} - {content} - - ) : ( -
    - Select a file to preview. -
    - )} -
    -
    - ); - } else { - // Narrow layout: viewer replaces the tree (master-detail). - body = filePath ? ( -
    - {renderFilePathBar(true)} - {content} -
    - ) : ( -
    - {uploadBanner} - {treeScroll} -
    - ); - } - - const moveBaseName = basenameOf(moveState.sourcePath); - const moveDestDir = moveState.value.trim().replace(/^\/+|\/+$/g, ""); - const moveResolvedPath = moveDestDir ? `${moveDestDir}/${moveBaseName}` : moveBaseName; - const moveSubmitDisabled = - moveState.mode === "rename" - ? moveState.value.trim().length === 0 - : moveResolvedPath === moveState.sourcePath; - - return ( - <> - - -
    - {body} -
    -
    - - setCreateFolderState((previous) => ({ ...previous, open }))} - > - - - New folder - - {createFolderState.parentDir - ? `Create a folder inside ${createFolderState.parentDir}.` - : "Create a folder in the project root."} - - - -
    { - event.preventDefault(); - void submitCreateFolder(); - }} - > - setNewFolderName(event.target.value)} - /> - {actionError ?

    {actionError}

    : null} -
    -
    - - - - -
    -
    - - { - if (!open) { - setDeleteTarget(null); - } - }} - > - - - - Delete {deleteTarget?.kind === "directory" ? "folder" : "file"} - {deleteTarget ? ` “${basenameOf(deleteTarget.path)}”` : ""}? - - - {deleteTarget?.kind === "directory" - ? "This permanently deletes the folder and everything inside it." - : "This permanently deletes the file."}{" "} - This cannot be undone. - - - {actionError ?

    {actionError}

    : null} - - }>Cancel - - -
    -
    - - setMoveState((previous) => ({ ...previous, open }))} - > - - - - {moveState.mode === "rename" ? "Rename" : "Move"}{" "} - {moveState.sourceKind === "directory" ? "folder" : "file"} - - - {moveState.mode === "rename" - ? `Enter a new name for “${basenameOf(moveState.sourcePath)}”.` - : `Choose a destination folder for “${basenameOf(moveState.sourcePath)}”.`} - - - - {moveState.mode === "rename" ? ( -
    { - event.preventDefault(); - void submitMove(); - }} - > - - setMoveState((previous) => ({ ...previous, value: event.target.value })) - } - /> -
    - ) : cwd && environmentId ? ( -
    - setMoveState((previous) => ({ ...previous, value: dir }))} - theme={resolvedTheme} - /> -

    - Destination: {moveResolvedPath} -

    -
    - ) : null} - {actionError ?

    {actionError}

    : null} -
    - - - - -
    -
    - - ); -} - -function PdfPreview(props: { contents: string; fileName: string }) { - const { contents, fileName } = props; - const [url, setUrl] = useState(null); - const [failed, setFailed] = useState(false); - - // Build a blob: URL from the base64 payload so the browser's native PDF - // viewer can render it in an iframe (avoids a multi-MB data: URI in the DOM). - useEffect(() => { - let objectUrl: string | null = null; - setUrl(null); - setFailed(false); - try { - const binary = atob(contents); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - objectUrl = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })); - setUrl(objectUrl); - } catch { - setFailed(true); - } - return () => { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } - }; - }, [contents]); - - if (failed) { - return ( -
    - Unable to preview this PDF. -
    - ); - } - if (!url) { - return ( -
    - Loading PDF… -
    - ); - } - return ( - -
    - This browser can't display PDFs inline. - - Download {fileName} - -
    -
    - ); -} - -function FileContentView(props: { - fileState: FileState; - filePath: string; - themeName: string; - cwd: string | null; - markdownView: "rendered" | "source"; -}) { - const { fileState, filePath, themeName, cwd, markdownView } = props; - - if (fileState.status === "loading" || fileState.status === "idle") { - return ( -
    - Loading file… -
    - ); - } - if (fileState.status === "error") { - return ( -
    - {fileState.error ?? "Failed to read file."} -
    - ); - } - - const data = fileState.data; - if (!data) { - return null; - } - - const isImage = data.encoding === "base64" && (data.mediaType?.startsWith("image/") ?? false); - if (isImage) { - if (data.truncated) { - return ( -
    - Image is too large to preview ({formatBytes(data.byteSize)}). -
    - ); - } - return ( -
    - {basenameOf(filePath)} -
    - ); - } - - const isPdf = - data.encoding === "base64" && (data.mediaType === "application/pdf" || isPdfPath(filePath)); - if (isPdf) { - if (data.truncated) { - return ( -
    - PDF is too large to preview ({formatBytes(data.byteSize)}). -
    - ); - } - return ( -
    - -
    - ); - } - - if (data.encoding === "base64") { - return ( -
    - Binary file ({formatBytes(data.byteSize)}). Preview not available. -
    - ); - } - - const renderMarkdown = isMarkdownPath(filePath) && markdownView === "rendered"; - - return ( -
    - {data.truncated ? ( -
    - Showing the first part of this file ({formatBytes(data.byteSize)} total). -
    - ) : null} - {renderMarkdown ? ( -
    - -
    - ) : ( -
    - -
    - )} -
    - ); -} - -interface FolderPickerDirState { - status: "loading" | "loaded" | "error"; - entries: ReadonlyArray; - error?: string; -} - -function MoveFolderPicker(props: { - cwd: string; - environmentId: EnvironmentId; - sourcePath: string; - sourceKind: "file" | "directory"; - selectedDir: string; - onSelect: (dir: string) => void; - theme: "light" | "dark"; -}) { - const { cwd, environmentId, sourcePath, sourceKind, selectedDir, onSelect, theme } = props; - const [dirChildren, setDirChildren] = useState>( - () => new Map(), - ); - const [expanded, setExpanded] = useState>(() => new Set()); - const dirChildrenRef = useRef(dirChildren); - useEffect(() => { - dirChildrenRef.current = dirChildren; - }, [dirChildren]); - - const loadDir = useCallback( - (dirPath: string) => { - const setState = (state: FolderPickerDirState) => - setDirChildren((previous) => new Map(previous).set(dirPath, state)); - const projects = readEnvironmentConnection(environmentId)?.client.projects; - if (!projects) { - setState({ status: "error", entries: [], error: "Not connected." }); - return; - } - setState({ status: "loading", entries: [] }); - projects - .listDirectory({ cwd, ...(dirPath ? { relativePath: dirPath } : {}) }) - .then((result) => { - const entries = result.entries - .filter( - (entry) => - entry.kind === "directory" && - // Can't move a folder into itself or a descendant. - !(sourceKind === "directory" && entry.path === sourcePath), - ) - .map((entry) => entry.path); - setState({ status: "loaded", entries }); - }) - .catch((error: unknown) => { - setState({ status: "error", entries: [], error: messageOfError(error) }); - }); - }, - [cwd, environmentId, sourcePath, sourceKind], - ); - - useEffect(() => { - loadDir(""); - }, [loadDir]); - - const toggle = useCallback( - (dirPath: string) => { - setExpanded((previous) => { - const next = new Set(previous); - if (next.has(dirPath)) { - next.delete(dirPath); - } else { - next.add(dirPath); - } - return next; - }); - if (!dirChildrenRef.current.has(dirPath)) { - loadDir(dirPath); - } - }, - [loadDir], - ); - - const renderChildren = (dirPath: string, depth: number): ReactNode => { - const state = dirChildren.get(dirPath); - const indent = 8 + depth * 14; - if (!state || state.status === "loading") { - return ( -
    - Loading… -
    - ); - } - if (state.status === "error") { - return ( -
    - {state.error} - -
    - ); - } - return state.entries.map((childPath) => { - const isExpanded = expanded.has(childPath); - const isSelected = selectedDir === childPath; - return ( -
    -
    - - -
    - {isExpanded ? renderChildren(childPath, depth + 1) : null} -
    - ); - }); - }; - - return ( -
    - - {renderChildren("", 0)} -
    - ); -} diff --git a/apps/web/src/components/FileEditor.tsx b/apps/web/src/components/FileEditor.tsx deleted file mode 100644 index c85bb4f9ddc1..000000000000 --- a/apps/web/src/components/FileEditor.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { LanguageDescription, type LanguageSupport } from "@codemirror/language"; -import { languages } from "@codemirror/language-data"; -import CodeMirror, { EditorView, type Extension } from "@uiw/react-codemirror"; -import { useEffect, useMemo, useState } from "react"; - -// Match the read-only viewer's compact monospace presentation and let the -// editor fill its flex slot (it manages its own internal scrolling). -const editorTheme = EditorView.theme({ - "&": { height: "100%", fontSize: "12px" }, -}); - -function basenameOf(path: string): string { - return path.slice(path.lastIndexOf("/") + 1); -} - -function extensionOf(filename: string): string { - const dot = filename.lastIndexOf("."); - return dot === -1 ? "" : filename.slice(dot + 1).toLowerCase(); -} - -// Extensions that @codemirror/language-data doesn't map but which we want -// highlighted as a known language (key: lowercase extension, value: the -// LanguageDescription name to fall back to). JSONC has no dedicated grammar, -// so it reuses the JSON mode. -const EXTENSION_OVERRIDES: Record = { - jsonc: "JSON", -}; - -function resolveLanguage(path: string): LanguageDescription | null { - const filename = basenameOf(path); - const matched = LanguageDescription.matchFilename(languages, filename); - if (matched) { - return matched; - } - const name = EXTENSION_OVERRIDES[extensionOf(filename)]; - return name ? LanguageDescription.matchLanguageName(languages, name) : null; -} - -export default function FileEditor(props: { - value: string; - filePath: string; - theme: "light" | "dark"; - onChange: (value: string) => void; -}) { - const { value, filePath, theme, onChange } = props; - const [language, setLanguage] = useState(null); - - // Resolve the language for this file and load it on demand. Each language - // mode in @codemirror/language-data is a separately code-split dynamic - // import, so only the modes the user actually opens are downloaded. - useEffect(() => { - let cancelled = false; - setLanguage(null); - const description = resolveLanguage(filePath); - if (!description) { - return; - } - description - .load() - .then((support) => { - if (!cancelled) { - setLanguage(support); - } - }) - .catch(() => { - // Highlighting is best-effort; the file still edits fine as plain text. - }); - return () => { - cancelled = true; - }; - }, [filePath]); - - const extensions = useMemo( - () => (language ? [editorTheme, language] : [editorTheme]), - [language], - ); - - return ( - - ); -} diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx deleted file mode 100644 index 013919febfff..000000000000 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ /dev/null @@ -1,456 +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", () => ({ - 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"); - expect(quickActionButton, 'Unable to find button containing "Push"').toBeTruthy(); - if (!(quickActionButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Push"'); - } - 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.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f2dcb3517919..1de41563841d 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1102,6 +1102,15 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.equal(update, null); }); + + it("allows a temporary worktree ref to reconcile to a semantic branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "t3code/a9628676", + gitStatus: status({ refName: "feature/diff-panel-toggle" }), + }); + + assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); + }); }); describe("resolveAutoFeatureBranchName", () => { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8090807a5776..c98167194524 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, @@ -44,7 +49,6 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; -import { CreatePrDialog } from "./CreatePrDialog"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -64,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, @@ -72,16 +76,19 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { 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; @@ -94,7 +101,6 @@ interface PendingDefaultBranchAction { branchName: string; includesCommit: boolean; commitMessage?: string; - baseBranch?: string; onConfirmed?: () => void; filePaths?: string[]; } @@ -125,12 +131,27 @@ interface RunGitActionWithToastInput { skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; - baseBranch?: string; progressToastId?: GitActionToastId; filePaths?: string[]; } 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 = [ @@ -351,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"); @@ -364,7 +393,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const [publishResult, setPublishResult] = useState( null, ); - const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, @@ -415,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; @@ -430,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; @@ -447,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; @@ -469,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, @@ -503,8 +519,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const resetState = useCallback(() => { setPublishRemoteName("origin"); - setPublishRepository(""); - setHasUserEditedPublishRepository(false); + setPublishRepositoryOverride(null); setPublishWizardStep(0); setPublishAdvancedOpen(false); setPublishError(null); @@ -597,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" > @@ -683,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") { @@ -954,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) @@ -972,13 +994,11 @@ 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()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); - const [isCreatePrDialogOpen, setIsCreatePrDialogOpen] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const activeGitActionProgressRef = useRef(null); @@ -1014,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; } @@ -1046,7 +1061,7 @@ export default function GitActionsControl({ activeThreadRef, draftId, setDraftThreadContext, - setThreadBranch, + updateThreadMetadata, ], ); @@ -1062,10 +1077,18 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const { data: gitStatus, error: gitStatusError } = useVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, + const gitStatusQuery = useEnvironmentQuery( + activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, }); + const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -1167,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 = () => { @@ -1188,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(); @@ -1209,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", @@ -1229,7 +1251,6 @@ export default function GitActionsControl({ skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, - baseBranch, progressToastId, filePaths, }: RunGitActionWithToastInput) => { @@ -1259,7 +1280,6 @@ export default function GitActionsControl({ branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), - ...(baseBranch ? { baseBranch } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -1359,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; } @@ -1367,105 +1387,104 @@ export default function GitActionsControl({ updateActiveProgressToast(); }; - const promise = runImmediateGitAction.run({ + const result = await runImmediateGitAction.run({ actionId, action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), - ...(baseBranch ? { baseBranch } : {}), ...(filePaths ? { filePaths } : {}), 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(); - if (toastCta.action.kind === "create_pr") { - setIsCreatePrDialogOpen(true); - return; - } - 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, + }); } }, ); const continuePendingDefaultBranchAction = () => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, baseBranch, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - ...(baseBranch ? { baseBranch } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, @@ -1474,13 +1493,11 @@ export default function GitActionsControl({ const checkoutFeatureBranchAndContinuePendingAction = () => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, baseBranch, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - ...(baseBranch ? { baseBranch } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), featureBranch: true, @@ -1516,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") { @@ -1548,10 +1581,6 @@ export default function GitActionsControl({ }); return; } - if (quickAction.action === "create_pr") { - setIsCreatePrDialogOpen(true); - return; - } if (quickAction.action) { void runGitActionWithToast({ action: quickAction.action }); } @@ -1568,7 +1597,7 @@ export default function GitActionsControl({ return; } if (item.dialogAction === "create_pr") { - setIsCreatePrDialogOpen(true); + void runGitActionWithToast({ action: "create_pr" }); return; } setExcludedFiles(new Set()); @@ -1592,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.", @@ -1602,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", @@ -1611,9 +1644,9 @@ export default function GitActionsControl({ ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), ); - }); + })(); }, - [gitCwd, threadToastData], + [gitCwd, openInPreferredEditor, threadToastData], ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; @@ -1628,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 } : {}), + }), + ); + })(); }} > @@ -1680,10 +1727,7 @@ export default function GitActionsControl({ { if (open) { - void refreshVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }).catch(() => undefined); + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); } }} > @@ -1764,7 +1808,7 @@ export default function GitActionsControl({

    )} {gitStatusError && ( -

    {gitStatusError.message}

    +

    {gitStatusError}

    )}
    @@ -1947,19 +1991,6 @@ export default function GitActionsControl({ gitCwd={gitCwd} /> - { - setIsCreatePrDialogOpen(false); - void runGitActionWithToast({ action: "create_pr", baseBranch }); - }} - /> - { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx deleted file mode 100644 index b7aa6d7a645b..000000000000 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ /dev/null @@ -1,636 +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 { 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 { - 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: [], - 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, - })), - 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("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), - http.get("*/api/project-favicon", () => 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}`] }), - ); - - 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 000000000000..de5a2123cde4 --- /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 000000000000..f6a47f50cfc6 --- /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 a2a801a3b4e8..68a5855c1a28 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,19 +10,17 @@ export function NoActiveThreadState() {
    {isElectron ? ( - + No active thread ) : (
    - No active thread diff --git a/apps/web/src/components/NotificationsControl.tsx b/apps/web/src/components/NotificationsControl.tsx deleted file mode 100644 index e410e07030e2..000000000000 --- a/apps/web/src/components/NotificationsControl.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { BellIcon, BellOffIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; - -import { readEnvironmentConnection } from "../environments/runtime"; -import { Button } from "./ui/button"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; - -const PUSH_SW_URL = "/push-sw.js"; - -function isPushSupported(): boolean { - return ( - typeof window !== "undefined" && - "serviceWorker" in navigator && - "PushManager" in window && - "Notification" in window - ); -} - -// VAPID public keys are base64url; PushManager wants a Uint8Array application key. -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - const raw = atob(base64); - const output = new Uint8Array(new ArrayBuffer(raw.length)); - for (let index = 0; index < raw.length; index += 1) { - output[index] = raw.charCodeAt(index); - } - return output; -} - -export function NotificationsControl({ environmentId }: { environmentId: EnvironmentId }) { - const supported = isPushSupported(); - const [open, setOpen] = useState(false); - const [permission, setPermission] = useState( - supported ? Notification.permission : "denied", - ); - const [subscribed, setSubscribed] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!supported) { - return; - } - let cancelled = false; - void navigator.serviceWorker - .getRegistration() - .then((registration) => registration?.pushManager.getSubscription() ?? null) - .then((subscription) => { - if (!cancelled) { - setSubscribed(Boolean(subscription)); - } - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [supported]); - - const pushClient = useCallback( - () => readEnvironmentConnection(environmentId)?.client.push ?? null, - [environmentId], - ); - - const enable = useCallback(async () => { - setError(null); - setBusy(true); - try { - const client = pushClient(); - if (!client) { - throw new Error("Not connected to the server."); - } - const result = await Notification.requestPermission(); - setPermission(result); - if (result !== "granted") { - return; - } - const status = await client.getStatus(); - if (!status.enabled || !status.vapidPublicKey) { - throw new Error("Server push is not configured."); - } - const registration = await navigator.serviceWorker.register(PUSH_SW_URL); - // register() resolves once the worker is installed, but pushManager.subscribe() - // requires an *active* worker — otherwise the first enable fails with - // "Subscription failed - no active Service Worker". Wait for activation. - const activeRegistration = registration.active - ? registration - : await navigator.serviceWorker.ready; - const subscription = await activeRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(status.vapidPublicKey), - }); - const json = subscription.toJSON(); - if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { - throw new Error("Browser returned an invalid subscription."); - } - await client.subscribe({ - endpoint: json.endpoint, - keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, - ...(typeof json.expirationTime === "number" ? { expirationTime: json.expirationTime } : {}), - }); - setSubscribed(true); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Failed to enable notifications."); - } finally { - setBusy(false); - } - }, [pushClient]); - - const disable = useCallback(async () => { - setError(null); - setBusy(true); - try { - const registration = await navigator.serviceWorker.getRegistration(); - const subscription = (await registration?.pushManager.getSubscription()) ?? null; - if (subscription) { - const endpoint = subscription.endpoint; - await subscription.unsubscribe(); - await pushClient()?.unsubscribe({ endpoint }); - } - setSubscribed(false); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Failed to disable notifications."); - } finally { - setBusy(false); - } - }, [pushClient]); - - if (!supported) { - return null; - } - - return ( - - - {subscribed ? : } - - } - /> - -
    -
    Notifications
    - {permission === "denied" ? ( -

    - Notifications are blocked. Enable them for this site in your browser settings, then - try again. -

    - ) : subscribed ? ( - - ) : ( - - )} - {error ?

    {error}

    : null} -

    - Get pinged when an agent finishes or needs approval — even with the tab closed. On a - phone, add this site to your home screen first for reliable delivery. -

    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 62bd1ba89db8..abc0db79b6cb 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,5 +1,9 @@ import { memo, useState, useCallback } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -11,7 +15,6 @@ import { ChevronRightIcon, EllipsisIcon, LoaderIcon, - PanelRightCloseIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import type { ActivePlanState } from "../session-logic"; @@ -25,9 +28,10 @@ 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 { useAtomCommand } from "~/state/use-atom-command"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { @@ -56,11 +60,11 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; label?: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar"; - onClose: () => void; + mode?: "sheet" | "sidebar" | "embedded"; } const PlanSidebar = memo(function PlanSidebar({ @@ -68,15 +72,18 @@ const PlanSidebar = memo(function PlanSidebar({ activeProposedPlan, label = "Plan", environmentId, + threadRef, markdownCwd, workspaceRoot, timestampFormat, mode = "sidebar", - onClose, }: 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; @@ -94,24 +101,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", @@ -119,12 +131,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 (
    ) : null} -
    @@ -257,6 +257,7 @@ const PlanSidebar = memo(function PlanSidebar({
    diff --git a/apps/web/src/components/PreviewControl.tsx b/apps/web/src/components/PreviewControl.tsx deleted file mode 100644 index 5a382ac1d6a6..000000000000 --- a/apps/web/src/components/PreviewControl.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import * as Schema from "effect/Schema"; -import { ExternalLinkIcon, GlobeIcon, PlusIcon, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; - -import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; -import { cn } from "~/lib/utils"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; - -const PreviewPortsSchema = Schema.Array( - Schema.Struct({ port: Schema.Number, path: Schema.String }), -); -type PreviewPort = { readonly port: number; readonly path: string }; - -function storageKey(cwd: string | null): string { - return `t3code_preview_ports:${cwd ?? "global"}`; -} - -function loadPorts(cwd: string | null): ReadonlyArray { - return getLocalStorageItem(storageKey(cwd), PreviewPortsSchema) ?? []; -} - -// Reuse the host the browser reached t3code through (e.g. the Tailscale name/IP) -// and swap in the dev-server port, so the preview rides the same connection. -function buildPreviewUrl(port: number, path: string): string { - const normalizedPath = path ? (path.startsWith("/") ? path : `/${path}`) : ""; - return `${window.location.protocol}//${window.location.hostname}:${port}${normalizedPath}`; -} - -function samePort(a: PreviewPort, b: PreviewPort): boolean { - return a.port === b.port && a.path === b.path; -} - -export function PreviewControl({ cwd }: { cwd: string | null }) { - const [open, setOpen] = useState(false); - const [ports, setPorts] = useState>(() => loadPorts(cwd)); - const [portInput, setPortInput] = useState(""); - const [pathInput, setPathInput] = useState(""); - - useEffect(() => { - setPorts(loadPorts(cwd)); - }, [cwd]); - - const persist = useCallback( - (next: ReadonlyArray) => { - setPorts(next); - setLocalStorageItem(storageKey(cwd), next, PreviewPortsSchema); - }, - [cwd], - ); - - const openPreview = useCallback((entry: PreviewPort) => { - window.open(buildPreviewUrl(entry.port, entry.path), "_blank", "noopener,noreferrer"); - }, []); - - const addPort = useCallback(() => { - const port = Number.parseInt(portInput.trim(), 10); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return; - } - const entry: PreviewPort = { port, path: pathInput.trim() }; - if (!ports.some((existing) => samePort(existing, entry))) { - persist([...ports, entry]); - } - setPortInput(""); - setPathInput(""); - openPreview(entry); - }, [portInput, pathInput, ports, persist, openPreview]); - - const removePort = useCallback( - (entry: PreviewPort) => { - persist(ports.filter((existing) => !samePort(existing, entry))); - }, - [ports, persist], - ); - - const previewOrigin = - typeof window === "undefined" ? "" : `${window.location.protocol}//${window.location.hostname}`; - - return ( - - - - - } - /> - -
    -
    Dev server preview
    - - {ports.length > 0 ? ( -
    - {ports.map((entry) => ( -
    - - -
    - ))} -
    - ) : ( -

    - No ports yet. Add the port your dev server runs on. -

    - )} - -
    { - event.preventDefault(); - addPort(); - }} - > - setPortInput(event.target.value)} - inputMode="numeric" - placeholder="3000" - aria-label="Port" - className="w-20" - /> - setPathInput(event.target.value)} - placeholder="/path (optional)" - aria-label="Path" - className="min-w-0 flex-1" - /> - -
    - -

    - Opens {previewOrigin}:<port> in a new tab. Bind dev servers to 0.0.0.0 so they're - reachable over your connection. -

    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index ad47e01bb111..481ddab05abb 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,49 +1,49 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; import { useState } from "react"; -import { resolveEnvironmentHttpUrl } from "../environments/runtime"; +import { useAssetUrl } from "../assets/assetUrls"; const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; - className?: string; + className?: string | undefined; }) { - const src = (() => { - try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); - } catch { - return null; - } - })(); - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", - ); + const src = useAssetUrl(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + }); 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 4588ac51bdd7..4438a671f5d5 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, @@ -85,16 +90,25 @@ export interface NewProjectScriptInput { icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null; + /** Optional URL to open in the in-app preview when this script runs. */ + previewUrl: string | null; + /** When true, automatically open the preview panel pointed at `previewUrl`. */ + 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({ @@ -115,6 +129,8 @@ export default function ProjectScriptsControl({ const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -155,6 +171,7 @@ export default function ProjectScriptsControl({ } setValidationError(null); + let payload: NewProjectScriptInput; try { const scriptIdForValidation = editingScriptId ?? @@ -166,23 +183,33 @@ export default function ProjectScriptsControl({ keybinding, command: commandForProjectScript(scriptIdForValidation), }); - const payload = { + const trimmedPreviewUrl = previewUrl.trim(); + payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, + 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 = () => { @@ -193,6 +220,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); setDialogOpen(true); }; @@ -205,6 +234,8 @@ export default function ProjectScriptsControl({ setIconPickerOpen(false); setRunOnWorktreeCreate(script.runOnWorktreeCreate); setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); + setPreviewUrl(script.previewUrl ?? ""); + setAutoOpenPreview(script.autoOpenPreview ?? false); setValidationError(null); setDialogOpen(true); }; @@ -327,6 +358,8 @@ export default function ProjectScriptsControl({ setIcon("play"); setRunOnWorktreeCreate(false); setKeybinding(""); + setPreviewUrl(""); + setAutoOpenPreview(false); setValidationError(null); }} open={dialogOpen} @@ -413,6 +446,18 @@ export default function ProjectScriptsControl({ onChange={(event) => setCommand(event.target.value)} />
    +
    + + setPreviewUrl(event.target.value)} + /> +

    + Open this URL in the in-app preview when this action runs. +

    +
    + {validationError &&

    {validationError}

    } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index aee1ffe90583..8d1f88183fe3 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 f45b2916ce4c..3f77974e0fec 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 69cd83bf8dc1..56814dba1e6b 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 688ea004f525..4004b4930c27 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/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 22960a7ba207..ead306f7c906 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,61 +1,480 @@ -import { useNavigate, useParams } from "@tanstack/react-router"; -import { DiffIcon, FilesIcon } from "lucide-react"; - -import { stripDiffSearchParams } from "../diffRouteSearch"; -import { stripFilesSearchParams } from "../filesRouteSearch"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { Toggle, ToggleGroup } from "./ui/toggle-group"; - -export type RightPanelTab = "diff" | "files"; - -/** - * Segmented switcher shared by the diff and file-browser panels. Switching tabs - * swaps the active right-panel route param (`diff` / `files`) while clearing the - * other tab's params so only one panel occupies the shared slot at a time. - */ -export function RightPanelTabs({ active }: { active: RightPanelTab }) { - const navigate = useNavigate(); - const threadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - - const switchTo = (tab: RightPanelTab) => { - if (!threadRef || tab === active) { - return; +import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { isElectron } from "~/env"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { faviconUrlForOrigin } from "~/lib/favicon"; +import { useTheme } from "~/hooks/useTheme"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + maximized?: boolean; + layoutControls?: ReactNode; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string | null; + pendingSurfaceIds: ReadonlySet; + previewSessions: Readonly>; + terminalLabelsById: ReadonlyMap; + onActivate: (surface: RightPanelSurface) => void; + onCloseSurface: (surface: RightPanelSurface) => void; + onCloseOtherSurfaces: (surface: RightPanelSurface) => void; + onCloseSurfacesToRight: (surface: RightPanelSurface) => void; + onCloseAllSurfaces: () => void; + onCopyFilePath: (relativePath: string) => void; + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + onAddFiles: () => void; + browserAvailable: boolean; + diffAvailable: boolean; + filesAvailable: boolean; + children: ReactNode; +} + +const SURFACE_DISABLED_REASONS = { + browser: "Browser previews are only available in the T3 Code desktop app.", + files: "Files are only available when a project is open.", + diff: "Diff is only available for server threads in Git repositories.", +} as const; + +type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; + +function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { + return ( + + + {props.reason} + + ); +} + +function SurfaceMenuItem(props: { + available: boolean; + disabledReason?: string; + onClick: () => void; + children: ReactNode; +}) { + const item = ( + + {props.children} + + ); + if (props.available || !props.disabledReason) return item; + return ; +} + +function RightPanelEmptyState(props: { + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + onAddFiles: () => void; + browserAvailable: boolean; + diffAvailable: boolean; + filesAvailable: boolean; +}) { + const actions = [ + { + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + available: props.browserAvailable, + disabledReason: SURFACE_DISABLED_REASONS.browser, + onClick: props.onAddBrowser, + }, + { + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + available: true, + disabledReason: null, + onClick: props.onAddTerminal, + }, + { + label: "Files", + description: "Browse and read workspace files.", + icon: Files, + available: props.filesAvailable, + disabledReason: SURFACE_DISABLED_REASONS.files, + onClick: props.onAddFiles, + }, + { + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + available: props.diffAvailable, + disabledReason: SURFACE_DISABLED_REASONS.diff, + onClick: props.onAddDiff, + }, + ] as const; + + return ( +
    +
    +
    +

    Open a surface

    +

    + Choose what to show in the right panel. +

    +
    +
    + {actions.map((action) => { + const Icon = action.icon; + const content = ( + <> + + {action.label} + + {action.description} + + + ); + if (action.available) { + return ( + + ); + } + const disabledCard = ( + + ); + return ( + + ); + })} +
    +
    +
    + ); +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, + terminalLabelsById: ReadonlyMap, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + case "files": + return "Files"; + case "file": + return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); + case "terminal": + return ( + terminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId) + ); + case "plan": + return "Plan"; + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; + if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; + try { + return new URL(snapshot.navStatus.url).host || "Browser"; + } catch { + return "Browser"; + } + } + } +} + +function PreviewFavicon({ url }: { url: string | null }) { + const faviconUrl = faviconUrlForOrigin(url, 32); + const [failedUrl, setFailedUrl] = useState(null); + if (!faviconUrl || failedUrl === faviconUrl) return ; + return ( + setFailedUrl(faviconUrl)} + /> + ); +} + +function SurfaceIcon({ + surface, + sessions, + theme, +}: { + surface: RightPanelSurface; + sessions: Readonly>; + theme: "light" | "dark"; +}) { + switch (surface.kind) { + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ; } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - replace: true, - search: (previous) => - tab === "files" - ? { ...stripDiffSearchParams(previous), files: "1" } - : { ...stripFilesSearchParams(previous), diff: "1" }, - }); - }; + case "diff": + return ; + case "files": + return ; + case "file": + return ( + + ); + case "terminal": + return ; + case "plan": + return ; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + const { resolvedTheme } = useTheme(); + const tabListRef = useRef(null); + + const handleTabContextMenu = useCallback( + async (event: ReactMouseEvent, surface: RightPanelSurface) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); + if (surfaceIndex < 0) return; + + const items: ContextMenuItem[] = []; + if (surface.kind === "file") { + items.push({ id: "copy-path", label: "Copy path" }); + } + items.push( + { id: "close", label: "Close" }, + { + id: "close-others", + label: "Close others", + disabled: props.surfaces.length <= 1, + }, + { + id: "close-to-right", + label: "Close to the right", + disabled: surfaceIndex >= props.surfaces.length - 1, + }, + { + id: "close-all", + label: "Close all", + disabled: props.surfaces.length === 0, + }, + ); + + const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); + switch (action) { + case "copy-path": + if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); + break; + case "close": + props.onCloseSurface(surface); + break; + case "close-others": + props.onCloseOtherSurfaces(surface); + break; + case "close-to-right": + props.onCloseSurfacesToRight(surface); + break; + case "close-all": + props.onCloseAllSurfaces(); + break; + case null: + break; + } + }, + [props], + ); + + useEffect(() => { + const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); + activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [props.activeSurfaceId]); return ( - { - const next = value[0]; - if (next === "diff" || next === "files") { - switchTo(next); - } - }} + - - - Diff - - - - Files - - +
    + +
    + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); + return ( +
    void handleTabContextMenu(event, surface)} + className={cn( + "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} + > + + props.onActivate(surface)} + > + + {title} + + } + /> + {title} + + +
    + ); + })} + {props.surfaces.length > 0 ? ( + + + + + + + + Browser + + + + Terminal + + + + Files + + + + Diff + + + + ) : null} +
    +
    + {props.layoutControls} +
    +
    + {props.activeSurfaceId === null ? ( + + ) : ( + props.children + )} +
    + ); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f84914..574e33d4dab7 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, @@ -11,10 +9,12 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -37,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; @@ -65,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", () => { @@ -171,6 +223,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { }); }); +describe("isTrailingDoubleClick", () => { + it("treats a single click as a normal activation", () => { + expect(isTrailingDoubleClick(1)).toBe(false); + }); + + it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => { + expect(isTrailingDoubleClick(0)).toBe(false); + }); + + it("ignores the second click of a double-click so it does not navigate", () => { + expect(isTrailingDoubleClick(2)).toBe(true); + }); + + it("ignores further clicks of a triple-click", () => { + expect(isTrailingDoubleClick(3)).toBe(true); + }); +}); + describe("resolveSidebarNewThreadEnvMode", () => { it("uses the app default when the caller does not request a specific mode", () => { expect( @@ -206,6 +276,7 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/draft", worktreePath: "/repo/.t3/worktrees/draft", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ @@ -247,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, }); }); @@ -327,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({ @@ -346,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", () => { @@ -481,11 +573,14 @@ describe("resolveThreadStatusPill", () => { latestTurn: null, lastVisitedAt: undefined, 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, }, }; @@ -530,14 +625,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: { @@ -546,11 +641,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", () => { @@ -564,7 +659,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -702,8 +797,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", @@ -720,7 +816,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -733,14 +828,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: [], ...overrides, }; @@ -815,8 +910,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({ @@ -827,9 +922,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", }, ], }), @@ -842,9 +938,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", }, ], }), @@ -863,12 +960,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", }), ], @@ -887,15 +984,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, }), ], [], @@ -910,8 +1007,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"); @@ -927,12 +1024,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 b9dd27dfb039..4e7614ed5516 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; @@ -160,6 +168,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +// A double-click dispatches two `click` events before `dblclick`: the first has +// `detail === 1`, the second `detail === 2`. The second click must not run the +// row's single-click navigation, otherwise double-click-to-rename would also +// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which +// still count as a normal single activation. +export function isTrailingDoubleClick(detail: number): boolean { + return detail > 1; +} + export function resolveSidebarNewThreadEnvMode(input: { requestedEnvMode?: SidebarNewThreadEnvMode; defaultEnvMode: SidebarNewThreadEnvMode; @@ -180,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 { @@ -197,6 +216,7 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + startFromOrigin: input.activeDraftThread.startFromOrigin, }; } @@ -217,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]; } @@ -358,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", @@ -536,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 dc5acaaadc71..ce925618caa0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + Globe2Icon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -16,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"; @@ -38,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 { @@ -51,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, @@ -60,22 +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 { useUiStateStore } from "../uiStateStore"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +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, @@ -84,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, @@ -157,14 +178,16 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + isTrailingDoubleClick, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -176,20 +199,16 @@ import { import { sortThreads } from "../lib/threadSort"; 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, @@ -220,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, @@ -232,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 { @@ -250,7 +284,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; + keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< @@ -289,6 +323,7 @@ interface SidebarThreadRowProps { renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -316,7 +351,7 @@ interface SidebarThreadRowProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, @@ -325,6 +360,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -349,35 +385,65 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const isMobile = useIsMobile(); + const discoveredPorts = useThreadDiscoveredPorts({ + 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({ @@ -419,6 +485,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) — only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -432,20 +516,48 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP 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], ); @@ -492,6 +604,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); @@ -558,6 +673,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isSelected, })} relative isolate`} onClick={handleRowClick} + onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} onContextMenu={handleRowContextMenu} > @@ -589,6 +705,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onKeyDown={handleRenameInputKeyDown} onBlur={handleRenameInputBlur} onClick={handleRenameInputClick} + onDoubleClick={handleRenameInputClick} /> ) : ( @@ -609,6 +726,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )}
    + {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} + {terminalStatus && ( void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -801,6 +940,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -852,6 +992,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} @@ -912,7 +1053,7 @@ interface SidebarProjectItemProps { isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; - handleNewThread: ReturnType["handleNewThread"]; + handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; @@ -945,27 +1086,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); @@ -1011,38 +1159,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( @@ -1059,8 +1177,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) => @@ -1146,7 +1265,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1244,15 +1362,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, ], ); @@ -1263,9 +1382,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( @@ -1288,7 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { setProjectRenameTarget(member); - setProjectRenameTitle(member.name); + setProjectRenameTitle(member.title); }, []); const openProjectGroupingDialog = useCallback( @@ -1303,28 +1422,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( @@ -1352,17 +1470,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}`] : []), @@ -1371,8 +1492,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}`] : []), @@ -1383,19 +1504,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, }), ); @@ -1408,8 +1542,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"); @@ -1418,19 +1552,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, }), ); @@ -1466,7 +1600,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); @@ -1588,6 +1722,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + // Ignore the trailing click of a plain double-click so it doesn't navigate + // while a double-click is starting an inline rename. Placed after the + // modifier branches so cmd/shift selection still processes every click. + if (isTrailingDoubleClick(event.detail)) { + return; + } + if (currentSelectionCount > 0) { clearSelection(); } @@ -1652,9 +1793,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); }, @@ -1674,7 +1828,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 = @@ -1686,7 +1840,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 @@ -1703,21 +1859,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( @@ -1735,16 +1909,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; } @@ -1762,9 +1950,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", @@ -1782,6 +1970,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const startThreadRename = useCallback((threadKey: string, title: string) => { + setRenamingThreadKey(threadKey); + setRenamingTitle(title); + renamingCommittedRef.current = false; + }, []); + const commitRename = useCallback( async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { const threadKey = scopedThreadKey(threadRef); @@ -1806,19 +2000,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", @@ -1829,7 +2019,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [updateThreadMetadata], ); const closeProjectRenameDialog = useCallback(() => { @@ -1851,32 +2041,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", @@ -1885,7 +2065,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -1928,7 +2108,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" }, @@ -1941,9 +2122,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); if (clicked === "rename") { - setRenamingThreadKey(threadKey); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; + startThreadRename(threadKey, thread.title); return; } @@ -1981,7 +2160,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, @@ -1990,7 +2179,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, memberProjectByScopedKey, - project.cwd, + project.workspaceRoot, + startThreadRename, ], ); @@ -2038,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} @@ -2106,13 +2296,14 @@ 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} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} @@ -2145,7 +2336,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."} @@ -2192,7 +2383,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."} @@ -2261,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2473,43 +2648,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(); @@ -2550,7 +2747,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType["updateSettings"]; + updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -2558,7 +2755,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[]; @@ -2811,21 +3008,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({ @@ -2833,8 +3030,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()); @@ -2842,20 +3044,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]); @@ -2884,19 +3095,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)), @@ -2949,15 +3150,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( () => ({ @@ -3020,9 +3216,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( @@ -3103,7 +3299,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 @@ -3154,19 +3353,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({ @@ -3202,18 +3393,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]); @@ -3300,39 +3479,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) @@ -3438,6 +3584,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 000000000000..07711ca84b7b --- /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 000000000000..868bd2cd99c0 --- /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 ed2df1c79a09..3e85920d1904 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,19 +1,21 @@ -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, TerminalIcon } from "lucide-react"; +import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, 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"; +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 { 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 { @@ -93,6 +95,40 @@ export function terminalStatusFromRunningIds( }; } +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, @@ -154,19 +190,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({ @@ -212,18 +251,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 56482c44e8e3..000000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ /dev/null @@ -1,418 +0,0 @@ -import "../index.css"; - -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { 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", () => ({ - 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"); - -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 af7739389568..1641bb6b109b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,9 +1,13 @@ +import { useAtomValue } from "@effect/atom-react"; import { FitAddon } from "@xterm/addon-fit"; import { - ChevronDown, - ChevronUp, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Plus, SquareSplitHorizontal, + SquareSplitVertical, TerminalSquare, Trash2, XIcon, @@ -11,8 +15,6 @@ import { import { type ResolvedKeybindingsConfig, type ScopedThreadRef, - type TerminalAttachStreamEvent, - type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -20,6 +22,7 @@ import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, + type SetStateAction, useCallback, useEffect, useEffectEvent, @@ -28,8 +31,9 @@ import { useState, } from "react"; 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, @@ -44,6 +48,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -53,9 +58,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 { useAtomCommand } from "../state/use-atom-command"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -76,10 +85,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); } } @@ -302,6 +311,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); @@ -317,6 +341,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; @@ -326,15 +382,12 @@ 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({ cursorBlink: true, - lineHeight: 1.2, + lineHeight: 1, fontSize: 12, scrollback: 5_000, fontFamily: @@ -347,6 +400,12 @@ export function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; + previousSessionRef.current = { + buffer: "", + status: "closed", + error: null, + version: 0, + }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -430,9 +489,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); } }; @@ -443,6 +502,7 @@ export function TerminalViewport({ if ( isTerminalToggleShortcut(event, currentKeybindings, options) || isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || isTerminalCloseShortcut(event, currentKeybindings, options) || isDiffToggleShortcut(event, currentKeybindings, options) @@ -511,28 +571,46 @@ 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") { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { + if (!localApi) { writeSystemMessage( latestTerminal, - error instanceof Error ? error.message : "Unable to open link", + "Opening links is unavailable in this browser.", ); + return; + } + const fallbackToBrowser = () => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + void openTerminalLinkInPreview({ + url: match.text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + openPreview, + localApi, + fallbackToBrowser, }); return; } 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", ); - }); + })(); }, })), ); @@ -540,14 +618,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(() => { @@ -593,107 +674,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, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { 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; @@ -704,21 +684,10 @@ 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(); return () => { - disposed = true; - unsubscribeAttach?.(); - unsubscribeAttach = null; window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose(); @@ -738,6 +707,65 @@ export function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, 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; @@ -751,24 +779,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); @@ -783,14 +803,13 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { + mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; - /** Collapsed to a slim restore bar while keeping sessions running. */ - minimized?: boolean; height: number; terminalIds: string[]; activeTerminalId: string; @@ -798,10 +817,10 @@ interface ThreadTerminalDrawerProps { activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; onNewTerminal: () => void; - onMinimize: () => void; - onRestore: () => void; splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; @@ -845,13 +864,13 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + mode = "drawer", threadRef, threadId, cwd, worktreePath, runtimeEnv, visible = true, - minimized = false, height, terminalIds, activeTerminalId, @@ -859,10 +878,10 @@ export default function ThreadTerminalDrawer({ activeTerminalGroupId, focusRequestId, onSplitTerminal, + onSplitTerminalVertical, onNewTerminal, - onMinimize, - onRestore, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -873,10 +892,33 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const isPanel = mode === "panel"; + 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; @@ -952,6 +994,9 @@ export default function ThreadTerminalDrawer({ nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, + ...(terminalGroup.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), }); } @@ -989,6 +1034,8 @@ export default function ThreadTerminalDrawer({ const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); + const splitDirection = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1015,22 +1062,29 @@ export default function ThreadTerminalDrawer({ [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` + ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel - ? `Split Terminal (${splitShortcutLabel})` - : "Split Terminal"; + ? `Split Terminal Horizontally (${splitShortcutLabel})` + : "Split Terminal Horizontally"; + const splitTerminalVerticalActionLabel = hasReachedSplitLimit + ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` + : splitVerticalShortcutLabel + ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` + : "Split Terminal Vertically"; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; - const minimizeTerminalActionLabel = "Minimize Terminal"; - const restoreTerminalActionLabel = "Restore Terminal"; const onSplitTerminalAction = useCallback(() => { if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); + const onSplitTerminalVerticalAction = useCallback(() => { + if (hasReachedSplitLimit) return; + onSplitTerminalVertical(); + }, [hasReachedSplitLimit, onSplitTerminalVertical]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); @@ -1051,11 +1105,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; @@ -1069,20 +1120,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) => { @@ -1110,7 +1164,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) { @@ -1140,16 +1194,22 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return ( - ); - } - const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); return (