diff --git a/.env.example b/.env.example index 067aad9cc6e..79b2adaf0c8 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ # T3CODE_CLERK_JWT_TEMPLATE=t3-relay # T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API +# hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk +# returns a different RP ID or when multiple domains must be entitled. +# T3CODE_APPLE_TEAM_ID=ABC1234567 +# T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com + # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f0eef0c1e..21fbce026f5 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 77d3bff06e5..a16763cb141 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 2348417abc5..168c000c38b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -425,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 }} @@ -452,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 diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md new file mode 100644 index 00000000000..d474c41d2fe --- /dev/null +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -0,0 +1,76 @@ +--- +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. +- 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. +- 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. + +## 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. If there are no findings, report exactly `All clear`. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bba35c8de8b..bb52416cc77 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,8 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@clerk/electron": "catalog:", + "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", @@ -20,6 +22,7 @@ "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "41.5.0", + "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", "react-grab": "^0.1.32" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs index c45f81268a6..a5dbdcfbe69 100644 --- a/apps/desktop/scripts/build-preview-annotation-css.mjs +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -1,23 +1,23 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { compile } from "tailwindcss"; -const directory = dirname(fileURLToPath(import.meta.url)); -const appRoot = join(directory, ".."); -const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); -const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); -const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); -const require = createRequire(import.meta.url); -const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); +const directory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const appRoot = NodePath.join(directory, ".."); +const sourcePath = NodePath.join(appRoot, "src", "preview", "Annotation.css"); +const preloadPath = NodePath.join(appRoot, "src", "preview", "PickPreload.ts"); +const outputPath = NodePath.join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); +const require = NodeModule.createRequire(import.meta.url); +const tailwindRoot = NodePath.dirname(require.resolve("tailwindcss/package.json")); const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ - readFile(sourcePath, "utf8"), - readFile(preloadPath, "utf8"), - readFile(join(tailwindRoot, "theme.css"), "utf8"), - readFile(join(tailwindRoot, "preflight.css"), "utf8"), + NodeFSP.readFile(sourcePath, "utf8"), + NodeFSP.readFile(preloadPath, "utf8"), + NodeFSP.readFile(NodePath.join(tailwindRoot, "theme.css"), "utf8"), + NodeFSP.readFile(NodePath.join(tailwindRoot, "preflight.css"), "utf8"), ]); const candidates = new Set( @@ -37,4 +37,4 @@ const encodedCss = `'${css .replaceAll("\n", "\\n")}'`; const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; -await writeFile(outputPath, moduleSource); +await NodeFSP.writeFile(outputPath, moduleSource); diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 58ccfe90eb9..c28d5ec358b 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,7 @@ -import { spawn, spawnSync } from "node:child_process"; -import { watch } from "node:fs"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import { join } from "node:path"; +import * as NodePath from "node:path"; import { desktopDir, @@ -64,7 +64,7 @@ function killChildTreeByPid(pid, signal) { return; } - spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" }); } function cleanupStaleDevApps() { @@ -72,7 +72,9 @@ function cleanupStaleDevApps() { return; } - spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { + stdio: "ignore", + }); } function startApp() { @@ -87,7 +89,7 @@ function startApp() { ? electronArgs : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; const electronCommand = resolveElectronLaunchCommand(launchArgs); - const app = spawn(electronCommand.electronPath, electronCommand.args, { + const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -180,8 +182,8 @@ function scheduleRestart() { function startWatchers() { for (const { directory, files } of watchedDirectories) { - const watcher = watch( - join(desktopDir, directory), + const watcher = NodeFS.watch( + NodePath.join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { if (typeof filename !== "string" || !files.has(filename)) { @@ -202,7 +204,9 @@ function killChildTree(signal) { } // Kill direct children as a final fallback in case normal shutdown leaves stragglers. - spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); + NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { + stdio: "ignore", + }); } async function shutdown(exitCode) { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 52b6dd5cc6e..69df02fb80d 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,29 +1,18 @@ // This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" -import { spawnSync } from "node:child_process"; -import { - copyFileSync, - chmodSync, - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { createRequire } from "node:module"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const __dirname = dirname(fileURLToPath(import.meta.url)); -export const desktopDir = resolve(__dirname, ".."); -const repoRoot = resolve(desktopDir, "..", ".."); -const devBundleIdSuffix = basename(repoRoot) +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +export const desktopDir = NodePath.resolve(__dirname, ".."); +const repoRoot = NodePath.resolve(desktopDir, "..", ".."); +const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; @@ -31,31 +20,36 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 11; -const defaultIconPath = join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +const LAUNCHER_VERSION = 12; +const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = NodePath.join( + repoRoot, + "assets", + "dev", + "blueprint-macos-1024.png", +); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. const hostPlatform = NodeOS.platform(); -function resolveDevelopmentProtocolCallbackPort() { - const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); - if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) { - return configuredPort + 1; - } - return 13774; -} - function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const replaceResult = NodeChildProcess.spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const insertResult = NodeChildProcess.spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (insertResult.status === 0) { return; } @@ -66,16 +60,24 @@ function setPlistString(plistPath, key, value) { function setPlistJson(plistPath, key, value) { const serialized = JSON.stringify(value); - const replaceResult = spawnSync("plutil", ["-replace", key, "-json", serialized, plistPath], { - encoding: "utf8", - }); + const replaceResult = NodeChildProcess.spawnSync( + "plutil", + ["-replace", key, "-json", serialized, plistPath], + { + encoding: "utf8", + }, + ); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync("plutil", ["-insert", key, "-json", serialized, plistPath], { - encoding: "utf8", - }); + const insertResult = NodeChildProcess.spawnSync( + "plutil", + ["-insert", key, "-json", serialized, plistPath], + { + encoding: "utf8", + }, + ); if (insertResult.status === 0) { return; } @@ -85,7 +87,7 @@ function setPlistJson(plistPath, key, value) { } function runChecked(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); + const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8" }); if (result.status === 0) { return; } @@ -99,8 +101,7 @@ function shellSingleQuote(value) { } function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs"); - const protocolCallbackUrl = `http://127.0.0.1:${resolveDevelopmentProtocolCallbackPort()}/auth/callback`; + const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); const envEntries = [ ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], ["T3CODE_PORT", process.env.T3CODE_PORT], @@ -109,28 +110,17 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], - ["T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED", "1"], - ["T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL", protocolCallbackUrl], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); - writeFileSync( + NodeFS.writeFileSync( targetBinaryPath, [ "#!/bin/sh", ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), - 'for arg in "$@"; do', - ' case "$arg" in', - " t3code-dev://auth/callback*)", - ' if [ -n "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" ]; then', - ' /usr/bin/curl -fsS --max-time 2 -X POST --data-binary "$arg" "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" >/dev/null 2>&1 && exit 0', - " fi", - " ;;", - " esac", - "done", `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, "", ].join("\n"), ); - chmodSync(targetBinaryPath, 0o755); + NodeFS.chmodSync(targetBinaryPath, 0o755); } function registerMacLauncherBundle(appBundlePath) { @@ -160,21 +150,24 @@ function registerMacLauncherBundle(appBundlePath) { } function ensureDevelopmentIconIcns(runtimeDir) { - const generatedIconPath = join(runtimeDir, "icon-dev.icns"); - mkdirSync(runtimeDir, { recursive: true }); + const generatedIconPath = NodePath.join(runtimeDir, "icon-dev.icns"); + NodeFS.mkdirSync(runtimeDir, { recursive: true }); - if (!existsSync(developmentMacIconPngPath)) { + if (!NodeFS.existsSync(developmentMacIconPngPath)) { return defaultIconPath; } - const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; - if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { + const sourceMtimeMs = NodeFS.statSync(developmentMacIconPngPath).mtimeMs; + if ( + NodeFS.existsSync(generatedIconPath) && + NodeFS.statSync(generatedIconPath).mtimeMs >= sourceMtimeMs + ) { return generatedIconPath; } - const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); - const iconsetDir = join(iconsetRoot, "icon.iconset"); - mkdirSync(iconsetDir, { recursive: true }); + const iconsetRoot = NodeFS.mkdtempSync(NodePath.join(runtimeDir, "dev-iconset-")); + const iconsetDir = NodePath.join(iconsetRoot, "icon.iconset"); + NodeFS.mkdirSync(iconsetDir, { recursive: true }); try { for (const size of [16, 32, 128, 256, 512]) { @@ -184,7 +177,7 @@ function ensureDevelopmentIconIcns(runtimeDir) { String(size), developmentMacIconPngPath, "--out", - join(iconsetDir, `icon_${size}x${size}.png`), + NodePath.join(iconsetDir, `icon_${size}x${size}.png`), ]); const retinaSize = size * 2; @@ -194,7 +187,7 @@ function ensureDevelopmentIconIcns(runtimeDir) { String(retinaSize), developmentMacIconPngPath, "--out", - join(iconsetDir, `icon_${size}x${size}@2x.png`), + NodePath.join(iconsetDir, `icon_${size}x${size}@2x.png`), ]); } @@ -207,12 +200,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { ); return defaultIconPath; } finally { - rmSync(iconsetRoot, { recursive: true, force: true }); + NodeFS.rmSync(iconsetRoot, { recursive: true, force: true }); } } function patchMainBundleInfoPlist(appBundlePath, iconPath) { - const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); @@ -224,9 +217,9 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { }, ]); - const resourcesDir = join(appBundlePath, "Contents", "Resources"); - copyFileSync(iconPath, join(resourcesDir, "icon.icns")); - copyFileSync(iconPath, join(resourcesDir, "electron.icns")); + const resourcesDir = NodePath.join(appBundlePath, "Contents", "Resources"); + NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "icon.icns")); + NodeFS.copyFileSync(iconPath, NodePath.join(resourcesDir, "electron.icns")); } function patchHelperBundleInfoPlists(appBundlePath) { @@ -238,7 +231,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { ]; for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { - const infoPlistPath = join( + const infoPlistPath = NodePath.join( appBundlePath, "Contents", "Frameworks", @@ -246,7 +239,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { "Contents", "Info.plist", ); - if (!existsSync(infoPlistPath)) { + if (!NodeFS.existsSync(infoPlistPath)) { continue; } @@ -262,34 +255,34 @@ function patchHelperBundleInfoPlists(appBundlePath) { function readJson(path) { try { - return JSON.parse(readFileSync(path, "utf8")); + return JSON.parse(NodeFS.readFileSync(path, "utf8")); } catch { return null; } } function buildMacLauncher(electronBinaryPath) { - const sourceAppBundlePath = resolve(dirname(electronBinaryPath), "../.."); - const runtimeDir = join(desktopDir, ".electron-runtime"); - const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); + const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; - const metadataPath = join(runtimeDir, "metadata.json"); + const metadataPath = NodePath.join(runtimeDir, "metadata.json"); - mkdirSync(runtimeDir, { recursive: true }); + NodeFS.mkdirSync(runtimeDir, { recursive: true }); const expectedMetadata = { launcherVersion: LAUNCHER_VERSION, sourceAppBundlePath, - sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, - iconMtimeMs: statSync(iconPath).mtimeMs, + sourceAppMtimeMs: NodeFS.statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, appProtocolSchemes: APP_PROTOCOL_SCHEMES, }; const currentMetadata = readJson(metadataPath); if ( - existsSync(targetBinaryPath) && + NodeFS.existsSync(targetBinaryPath) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -297,18 +290,21 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } - rmSync(targetAppBundlePath, { recursive: true, force: true }); + NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); // verbatimSymlinks keeps the framework's relative symlinks intact // (e.g. Resources -> Versions/Current/Resources). Without it cpSync // rewrites them to absolute paths into node_modules, which escape the // bundle and crash sandboxed helper processes (icudtl.dat not found). - cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true }); + NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { + recursive: true, + verbatimSymlinks: true, + }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); } - writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); return targetBinaryPath; @@ -319,9 +315,9 @@ function isLinuxSetuidSandboxConfigured(electronBinaryPath) { return true; } - const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + const sandboxPath = NodePath.join(NodePath.dirname(electronBinaryPath), "chrome-sandbox"); try { - const sandboxStat = statSync(sandboxPath); + const sandboxStat = NodeFS.statSync(sandboxPath); return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; } catch { return false; @@ -342,7 +338,7 @@ function resolveLinuxSandboxArgs(electronBinaryPath) { export function resolveElectronPath() { ensureElectronRuntime(); - const require = createRequire(import.meta.url); + const require = NodeModule.createRequire(import.meta.url); const electronBinaryPath = require("electron"); if (hostPlatform !== "darwin") { @@ -365,11 +361,11 @@ export function resolveDevProtocolClient() { return null; } - const require = createRequire(import.meta.url); + const require = NodeModule.createRequire(import.meta.url); const electronBinaryPath = require("electron"); const launcherBinaryPath = buildMacLauncher(electronBinaryPath); return { - appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."), + appBundlePath: NodePath.resolve(launcherBinaryPath, "..", "..", ".."), appBundleId: APP_BUNDLE_ID, }; } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 0a13506d341..c37838ab183 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,14 +1,14 @@ -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { arch, platform, tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. -const hostPlatform = platform(); +const hostPlatform = NodeOS.platform(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. -const hostArch = arch(); +const hostArch = NodeOS.arch(); function getPlatformPath() { switch (hostPlatform) { @@ -27,26 +27,28 @@ function getPlatformPath() { function ensureExecutable(filePath) { if (hostPlatform !== "win32") { - chmodSync(filePath, 0o755); + NodeFS.chmodSync(filePath, 0o755); } } function repairPathFile(electronDir, platformPath) { - const pathFile = join(electronDir, "path.txt"); - const currentPath = existsSync(pathFile) ? readFileSync(pathFile, "utf8") : undefined; + const pathFile = NodePath.join(electronDir, "path.txt"); + const currentPath = NodeFS.existsSync(pathFile) + ? NodeFS.readFileSync(pathFile, "utf8") + : undefined; if (currentPath !== platformPath) { - writeFileSync(pathFile, platformPath); + NodeFS.writeFileSync(pathFile, platformPath); } } function getRequiredRuntimePaths(electronDir, platformPath) { - const paths = [join(electronDir, "dist", platformPath)]; + const paths = [NodePath.join(electronDir, "dist", platformPath)]; if (hostPlatform === "darwin") { paths.push( - join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), - join( + NodePath.join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), + NodePath.join( electronDir, "dist", "Electron.app", @@ -66,7 +68,7 @@ function isMachO(filePath) { return true; } - const result = spawnSync("file", ["-b", filePath], { + const result = NodeChildProcess.spawnSync("file", ["-b", filePath], { encoding: "utf8", }); @@ -75,7 +77,7 @@ function isMachO(filePath) { function missingRuntimePaths(electronDir, platformPath) { return getRequiredRuntimePaths(electronDir, platformPath).filter((runtimePath) => { - return !existsSync(runtimePath); + return !NodeFS.existsSync(runtimePath); }); } @@ -85,8 +87,8 @@ function invalidRuntimePaths(electronDir, platformPath) { } return [ - join(electronDir, "dist", platformPath), - join( + NodePath.join(electronDir, "dist", platformPath), + NodePath.join( electronDir, "dist", "Electron.app", @@ -95,11 +97,11 @@ function invalidRuntimePaths(electronDir, platformPath) { "Electron Framework.framework", "Electron Framework", ), - ].filter((runtimePath) => existsSync(runtimePath) && !isMachO(runtimePath)); + ].filter((runtimePath) => NodeFS.existsSync(runtimePath) && !isMachO(runtimePath)); } function runChecked(command, args) { - const result = spawnSync(command, args, { + const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8", stdio: "inherit", }); @@ -114,8 +116,8 @@ function runChecked(command, args) { } function installElectronRuntime(electronDir, version) { - const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-electron-")); + const zipPath = NodePath.join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ @@ -125,34 +127,34 @@ function installElectronRuntime(electronDir, version) { zipPath, ]); if (hostPlatform === "darwin") { - runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); + runChecked("ditto", ["-x", "-k", zipPath, NodePath.join(electronDir, "dist")]); } else { runChecked("python3", [ "-c", "import os, sys, zipfile; os.makedirs(sys.argv[2], exist_ok=True); zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])", zipPath, - join(electronDir, "dist"), + NodePath.join(electronDir, "dist"), ]); } } finally { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } } export function ensureElectronRuntime() { const electronPackageJsonPath = require.resolve("electron/package.json"); - const electronPackageJson = JSON.parse(readFileSync(electronPackageJsonPath, "utf8")); - const electronDir = dirname(electronPackageJsonPath); + const electronPackageJson = JSON.parse(NodeFS.readFileSync(electronPackageJsonPath, "utf8")); + const electronDir = NodePath.dirname(electronPackageJsonPath); const platformPath = getPlatformPath(); - const electronPath = join(electronDir, "dist", platformPath); + const electronPath = NodePath.join(electronDir, "dist", platformPath); const missingBeforeInstall = missingRuntimePaths(electronDir, platformPath); const invalidBeforeInstall = invalidRuntimePaths(electronDir, platformPath); if (missingBeforeInstall.length > 0 || invalidBeforeInstall.length > 0) { - if (existsSync(join(electronDir, "dist"))) { - rmSync(join(electronDir, "dist"), { recursive: true, force: true }); + if (NodeFS.existsSync(NodePath.join(electronDir, "dist"))) { + NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); } - rmSync(join(electronDir, "path.txt"), { force: true }); + NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); installElectronRuntime(electronDir, electronPackageJson.version); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 48a2e168a2b..fea5f0a120e 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,16 +1,16 @@ -import { spawn } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const desktopDir = resolve(__dirname, ".."); -const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const desktopDir = NodePath.resolve(__dirname, ".."); +const mainJs = NodePath.resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); const electronCommand = resolveElectronLaunchCommand([mainJs]); -const child = spawn(electronCommand.electronPath, electronCommand.args, { +const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index d959b4ab1f0..ecabd81fb40 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import * as NodeChildProcess from "node:child_process"; import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; @@ -6,7 +6,7 @@ const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); -const child = spawn(electronCommand.electronPath, electronCommand.args, { +const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/wait-for-resources.mjs b/apps/desktop/scripts/wait-for-resources.mjs index 2b0a60c5d98..00455f4db72 100644 --- a/apps/desktop/scripts/wait-for-resources.mjs +++ b/apps/desktop/scripts/wait-for-resources.mjs @@ -1,13 +1,13 @@ -import * as FileSystem from "node:fs/promises"; -import * as Net from "node:net"; -import * as Path from "node:path"; -import * as Timers from "node:timers/promises"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodePath from "node:path"; +import * as NodeTimersPromises from "node:timers/promises"; const defaultTcpHosts = ["127.0.0.1", "localhost", "::1"]; async function fileExists(filePath) { try { - await FileSystem.access(filePath); + await NodeFSP.access(filePath); return true; } catch { return false; @@ -16,7 +16,7 @@ async function fileExists(filePath) { function tcpPortIsReady({ host, port, connectTimeoutMs = 500 }) { return new Promise((resolveReady) => { - const socket = Net.createConnection({ host, port }); + const socket = NodeNet.createConnection({ host, port }); let settled = false; const finish = (ready) => { @@ -47,7 +47,7 @@ async function resolvePendingResources({ baseDir, files, tcpPort, tcpHosts, conn const pendingFiles = []; for (const relativeFilePath of files) { - const ready = await fileExists(Path.resolve(baseDir, relativeFilePath)); + const ready = await fileExists(NodePath.resolve(baseDir, relativeFilePath)); if (!ready) { pendingFiles.push(relativeFilePath); } @@ -114,6 +114,6 @@ export async function waitForResources({ ); } - await Timers.setTimeout(intervalMs); + await NodeTimersPromises.setTimeout(intervalMs); } } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4da1ce63bdf..f498c3340e6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -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"; @@ -100,12 +101,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 +164,16 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const rendererTarget = environment.isDevelopment + ? Option.getOrThrow(environment.devServerUrl) + : backendConfig.httpBaseUrl; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + targetOrigin: rendererTarget, + backendOrigin: backendConfig.httpBaseUrl, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -189,9 +200,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 +219,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 +228,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 +238,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/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index eafdbf056dc..3c95b266bc1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import type * as Electron from "electron"; @@ -63,7 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, - } satisfies ElectronApp.ElectronAppShape); + } satisfies ElectronApp.ElectronApp["Service"]); const makeAssetsLayer = (png: Option.Option) => Layer.succeed(DesktopAssets.DesktopAssets, { @@ -73,7 +74,7 @@ const makeAssetsLayer = (png: Option.Option) => png, }), resolveResourcePath: () => Effect.succeed(Option.none()), - } satisfies DesktopAssets.DesktopAssetsShape); + } satisfies DesktopAssets.DesktopAssets["Service"]); const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { const { env, ...environmentOverrides } = overrides; @@ -105,6 +106,7 @@ const withIdentity = ( readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; readonly legacyPathExists?: boolean; + readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; } = {}, @@ -121,7 +123,11 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + input.legacyPathProbeError + ? Effect.fail(input.legacyPathProbeError) + : Effect.succeed( + input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), + ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), }), @@ -147,6 +153,33 @@ describe("DesktopAppIdentity", () => { ), ); + it.effect("preserves failures while inspecting the legacy userData path", () => { + const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + description: "permission denied", + pathOrDescriptor: legacyPath, + }); + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); + + assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); + assert.equal(error.legacyPath, legacyPath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, + ); + }), + { legacyPathProbeError: cause }, + ); + }); + it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 52f4b12808e..385e694338d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,14 +18,24 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export interface DesktopAppIdentityShape { - readonly resolveUserDataPath: Effect.Effect; - readonly configure: Effect.Effect; +export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( + "DesktopUserDataPathResolutionError", + { + legacyPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; + } } export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, - DesktopAppIdentityShape + { + readonly resolveUserDataPath: Effect.Effect; + readonly configure: Effect.Effect; + } >()("@t3tools/desktop/app/DesktopAppIdentity") {} const normalizeCommitHash = (value: string): Option.Option => { @@ -35,7 +45,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -85,9 +95,15 @@ const make = Effect.gen(function* () { environment.appDataDirectory, environment.legacyUserDataDirName, ); - const legacyPathExists = yield* fileSystem - .exists(legacyPath) - .pipe(Effect.orElseSucceed(() => false)); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); return legacyPathExists ? legacyPath : environment.path.join(environment.appDataDirectory, environment.userDataDirName); diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts new file mode 100644 index 00000000000..2eb55c72057 --- /dev/null +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -0,0 +1,57 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); + +describe("DesktopAssets", () => { + it.effect("preserves the failed asset candidate and filesystem cause", () => + Effect.gen(function* () { + const fileName = "custom.bin"; + const candidatePath = "/repo/apps/desktop/resources/custom.bin"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: candidatePath, + description: "private filesystem diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), + }); + const assetsLayer = DesktopAssets.layer.pipe( + Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)), + ); + const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(assetsLayer)); + + const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); + + assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); + assert.equal(error.fileName, fileName); + assert.equal(error.candidatePath, candidatePath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, + ); + assert.notInclude(error.message, "private filesystem diagnostic"); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 3b5a15e435f..95585acab74 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -12,27 +13,47 @@ export interface DesktopIconPaths { readonly png: Option.Option; } -export interface DesktopAssetsShape { - readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: (fileName: string) => Effect.Effect>; +export class DesktopAssetProbeError extends Schema.TaggedErrorClass()( + "DesktopAssetProbeError", + { + fileName: Schema.String, + candidatePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to probe desktop asset "${this.fileName}" at ${this.candidatePath}.`; + } } -export class DesktopAssets extends Context.Service()( - "@t3tools/desktop/app/DesktopAssets", -) {} +export class DesktopAssets extends Context.Service< + DesktopAssets, + { + readonly iconPaths: Effect.Effect; + readonly resolveResourcePath: ( + fileName: string, + ) => Effect.Effect, DesktopAssetProbeError>; + } +>()("@t3tools/desktop/app/DesktopAssets") {} const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( fileName: string, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const candidates = environment.resolveResourcePathCandidates(fileName); for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fileSystem + .exists(candidate) + .pipe( + Effect.mapError( + (cause) => new DesktopAssetProbeError({ fileName, candidatePath: candidate, cause }), + ), + ); if (exists) { return Option.some(candidate); } @@ -44,16 +65,23 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; - const developmentDockIconExists = yield* fileSystem - .exists(developmentDockIconPath) - .pipe(Effect.orElseSucceed(() => false)); + const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe( + Effect.mapError( + (cause) => + new DesktopAssetProbeError({ + fileName: "icon.png", + candidatePath: developmentDockIconPath, + cause, + }), + ), + ); if (developmentDockIconExists) { return Option.some(developmentDockIconPath); } @@ -62,7 +90,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( return yield* resolveResourcePath(`icon.${ext}`); }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const context = yield* Effect.context< FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment >(); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts new file mode 100644 index 00000000000..ec29d54f44a --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -0,0 +1,280 @@ +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 writeBytes: (chunk: Uint8Array) => Effect.Effect; + readonly writeText: (chunk: string) => Effect.Effect; +} + +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") {} + +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})`; + } +} + +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.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 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 make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const writer = yield* makeRotatingLogFileWriter({ + filePath: environment.path.join(environment.logDir, "server-child.log"), + }).pipe(Effect.option); + + const service = Option.match(writer, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( + function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); + }, + ), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) satisfies DesktopBackendOutputLog["Service"], + }); + + return DesktopBackendOutputLog.of(service); +}); + +export const layer = Layer.effect(DesktopBackendOutputLog, make); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts new file mode 100644 index 00000000000..9b5ed56d1f3 --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -0,0 +1,149 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ + createClerkBridgeMock: vi.fn(), + storageAdapter: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + storageMock: vi.fn(), +})); + +vi.mock("@clerk/electron", () => ({ + createClerkBridge: createClerkBridgeMock, +})); + +vi.mock("@clerk/electron/storage", () => ({ + storage: storageMock, +})); + +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const makeDesktopClerkLayer = (isDevelopment = true) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + return DesktopClerk.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ); +}; + +describe("DesktopClerk", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + storageMock.mockReset(); + }); + + it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { + const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; + + assert.equal( + DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), + "clerk.t3.codes", + ); + assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); + assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); + }); + + it.effect("acquires and releases the SDK bridge with the layer", () => { + const cleanup = vi.fn(); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup }); + + return Effect.gen(function* () { + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + + assert.deepEqual(createClerkBridgeMock.mock.calls, [ + [ + { + storage: storageAdapter, + passkeys: true, + renderer: { scheme: "t3code-dev", host: "app" }, + }, + ], + ]); + assert.equal(cleanup.mock.calls.length, 1); + storageMock.mockClear(); + createClerkBridgeMock.mockClear(); + }); + }); + + it.effect("preserves bridge initialization failures", () => { + const cause = new Error("bridge initialization failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const error = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe(Effect.flip); + + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, true); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', + ); + }); + }); + + it.effect("preserves bridge cleanup failures", () => { + const cause = new Error("bridge cleanup failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ + cleanup: () => { + throw cause; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(Effect.scoped(Layer.build(makeDesktopClerkLayer(false)))); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', + ); + } + }); + }); + + it.each([ + { isDevelopment: true, scheme: "t3code-dev" }, + { isDevelopment: false, scheme: "t3code" }, + ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { + const bridge = { cleanup: vi.fn() }; + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue(bridge); + + assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); + assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); + assert.deepEqual(createClerkBridgeMock.mock.calls, [ + [ + { + storage: storageAdapter, + passkeys: true, + renderer: { scheme, host: "app" }, + }, + ], + ]); + storageMock.mockClear(); + createClerkBridgeMock.mockClear(); + }); +}); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts new file mode 100644 index 00000000000..0e283f8dd0c --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -0,0 +1,135 @@ +import { createClerkBridge } from "@clerk/electron"; +import { storage } from "@clerk/electron/storage"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; + +export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeInitializationError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerkBridgeCleanupError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeCleanupError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerk extends Context.Service< + DesktopClerk, + { + readonly configure: Effect.Effect< + void, + never, + ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + >; + } +>()("@t3tools/desktop/app/DesktopClerk") {} + +export function resolveDesktopClerkFrontendApiHostname( + publishableKey: string | undefined, +): string | undefined { + const normalizedKey = publishableKey?.trim(); + if (!normalizedKey) return undefined; + + try { + return clerkFrontendApiHostnameFromPublishableKey(normalizedKey); + } catch { + return undefined; + } +} + +export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( + typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" + ? undefined + : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, +); + +export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { + return createClerkBridge({ + storage: storage({ path: stateDir }), + passkeys: true, + renderer: { + scheme: ElectronProtocol.getDesktopScheme(isDevelopment), + host: ElectronProtocol.DESKTOP_HOST, + }, + }); +} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* Effect.acquireRelease( + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }), + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), + ); + + return DesktopClerk.of({ + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + if (!(yield* electronApp.requestSingleInstanceLock)) { + yield* electronApp.quit; + return yield* Effect.interrupt; + } + + yield* electronApp.on("second-instance", () => { + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }); + }).pipe(Effect.withSpan("desktop.clerk.configure")), + }); +}); + +export const layer = Layer.effect(DesktopClerk, make); diff --git a/apps/desktop/src/app/DesktopCloudAuth.test.ts b/apps/desktop/src/app/DesktopCloudAuth.test.ts deleted file mode 100644 index 002fd86b0a4..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuth.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -interface CloudAuthHarness { - readonly app: ElectronApp.ElectronAppShape; - readonly window: ElectronWindow.ElectronWindowShape; - readonly listeners: Map void)[]>; - readonly protocolRegistrations: { - readonly protocol: string; - readonly path?: string; - readonly args?: readonly string[]; - }[]; - readonly sends: { readonly channel: string; readonly args: readonly unknown[] }[]; - readonly reveals: unknown[]; - readonly layer: Layer.Layer< - | DesktopCloudAuth.DesktopCloudAuth - | DesktopEnvironment.DesktopEnvironment - | ElectronApp.ElectronApp - | ElectronWindow.ElectronWindow - >; -} - -function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarness { - const listeners = new Map void)[]>(); - const protocolRegistrations: CloudAuthHarness["protocolRegistrations"] = []; - const sends: CloudAuthHarness["sends"] = []; - const reveals: unknown[] = []; - const mainWindow = { id: "main-window" }; - - const app = ElectronApp.ElectronApp.of({ - metadata: Effect.succeed({ - appVersion: "0.0.0-test", - appPath: "/tmp/t3-code-test", - isPackaged: !input.isDevelopment, - resourcesPath: "/tmp/t3-code-test/resources", - runningUnderArm64Translation: false, - }), - name: Effect.succeed("T3 Code"), - whenReady: Effect.void, - quit: Effect.void, - exit: () => Effect.void, - relaunch: () => Effect.void, - setPath: () => Effect.void, - setName: () => Effect.void, - setAboutPanelOptions: () => Effect.void, - setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), - isDefaultProtocolClient: () => Effect.succeed(false), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - protocolRegistrations.push({ - protocol, - ...(path === undefined ? {} : { path }), - ...(args === undefined ? {} : { args }), - }); - return true; - }), - setDesktopName: () => Effect.void, - setDockIcon: () => Effect.void, - appendCommandLineSwitch: () => Effect.void, - on: (eventName, listener) => - Effect.sync(() => { - const erasedListener = listener as (...args: readonly unknown[]) => void; - listeners.set(eventName, [...(listeners.get(eventName) ?? []), erasedListener]); - }), - }); - - const window = ElectronWindow.ElectronWindow.of({ - create: () => Effect.die("not used"), - main: Effect.succeed(Option.some(mainWindow as never)), - currentMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), - focusedMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: (target) => - Effect.sync(() => { - reveals.push(target); - }), - sendAll: (channel, ...args) => - Effect.sync(() => { - sends.push({ channel, args }); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - }); - - const environment = DesktopEnvironment.DesktopEnvironment.of({ - isDevelopment: input.isDevelopment, - } as DesktopEnvironment.DesktopEnvironmentShape); - const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment); - - return { - app, - window, - listeners, - protocolRegistrations, - sends, - reveals, - layer: Layer.mergeAll( - DesktopCloudAuth.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provide(NodeServices.layer), - ), - Layer.succeed(ElectronApp.ElectronApp, app), - Layer.succeed(ElectronWindow.ElectronWindow, window), - ), - }; -} - -function emitAppEvent( - harness: CloudAuthHarness, - eventName: string, - ...args: readonly unknown[] -): void { - for (const listener of harness.listeners.get(eventName) ?? []) { - listener(...args); - } -} - -const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve()); - -describe("DesktopCloudAuth", () => { - it("uses separate callback schemes for packaged and development builds", () => { - assert.equal( - DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: false }), - "t3code", - ); - assert.equal( - DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: true }), - "t3code-dev", - ); - }); - - it("builds a native callback URL with request state", () => { - assert.equal( - DesktopCloudAuth.buildCloudAuthCallbackUrl({ - scheme: "t3code", - state: "state-1", - }), - "t3code://auth/callback?t3_state=state-1", - ); - }); - - it("accepts only the expected scheme, host, path, and state", () => { - assert.isNotNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=state-1", - scheme: "t3code", - state: "state-1", - }), - ); - assert.isNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=wrong", - scheme: "t3code", - state: "state-1", - }), - ); - assert.isNull( - DesktopCloudAuth.parseCloudAuthCallbackUrl({ - rawUrl: "https://example.com/callback?rotating_token_nonce=nonce&t3_state=state-1", - scheme: "t3code", - state: "state-1", - }), - ); - }); - - it("builds a native development callback URL with request state", () => { - assert.equal( - DesktopCloudAuth.buildCloudAuthCallbackUrl({ - scheme: "t3code-dev", - state: "state-1", - }), - "t3code-dev://auth/callback?t3_state=state-1", - ); - }); - - it.effect("registers the development protocol client and dispatches matching callbacks", () => { - const harness = makeHarness({ isDevelopment: true }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const callbackUrl = new URL(redirectUrl); - callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); - - let prevented = false; - emitAppEvent( - harness, - "open-url", - { preventDefault: () => (prevented = true) }, - callbackUrl.toString(), - ); - yield* flushCloudAuthDispatch; - - assert.isTrue(prevented); - assert.deepEqual( - harness.protocolRegistrations.map((registration) => registration.protocol), - ["t3code-dev"], - ); - assert.isString(harness.protocolRegistrations[0]?.path); - assert.isArray(harness.protocolRegistrations[0]?.args); - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [callbackUrl.toString()], - }, - ]); - assert.lengthOf(harness.reveals, 1); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }); - - it.effect("rejects mismatched callback state and only consumes the pending request once", () => { - const harness = makeHarness({ isDevelopment: false }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const validCallback = new URL(redirectUrl); - validCallback.searchParams.set("rotating_token_nonce", "nonce-1"); - const invalidCallback = new URL(validCallback); - invalidCallback.searchParams.set(DesktopCloudAuth.CLOUD_AUTH_CALLBACK_STATE_PARAM, "wrong"); - - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - invalidCallback.toString(), - ); - yield* flushCloudAuthDispatch; - assert.deepEqual(harness.sends, []); - - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - validCallback.toString(), - ); - yield* flushCloudAuthDispatch; - emitAppEvent( - harness, - "open-url", - { preventDefault: () => undefined }, - validCallback.toString(), - ); - yield* flushCloudAuthDispatch; - - assert.deepEqual( - harness.protocolRegistrations.map((registration) => registration.protocol), - ["t3code"], - ); - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [validCallback.toString()], - }, - ]); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }); - - it.effect( - "routes second-instance callbacks and reveals the window for non-callback launches", - () => { - const harness = makeHarness({ isDevelopment: true }); - - return Effect.gen(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - yield* cloudAuth.configure; - const redirectUrl = yield* cloudAuth.createRequest; - const callbackUrl = new URL(redirectUrl); - callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); - - emitAppEvent(harness, "second-instance", {}, ["electron", callbackUrl.toString()]); - yield* flushCloudAuthDispatch; - - const revealCountAfterCallback = harness.reveals.length; - emitAppEvent(harness, "second-instance", {}, ["electron", "--opened-from-dock"]); - yield* flushCloudAuthDispatch; - - assert.deepEqual(harness.sends, [ - { - channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - args: [callbackUrl.toString()], - }, - ]); - assert.equal(revealCountAfterCallback, 1); - assert.equal(harness.reveals.length, 2); - }).pipe(Effect.provide(harness.layer), Effect.scoped); - }, - ); -}); diff --git a/apps/desktop/src/app/DesktopCloudAuth.ts b/apps/desktop/src/app/DesktopCloudAuth.ts deleted file mode 100644 index 732de27b9ab..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuth.ts +++ /dev/null @@ -1,330 +0,0 @@ -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Scope from "effect/Scope"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; - -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import type * as Electron from "electron"; - -export const CLOUD_AUTH_CALLBACK_HOST = "auth"; -export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback"; -export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state"; -export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code"; -export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev"; - -const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; - -export class DesktopCloudAuthCallbackServerError extends Data.TaggedError( - "DesktopCloudAuthCallbackServerError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to start the desktop cloud auth callback server."; - } -} - -interface PendingCloudAuthRequest { - readonly state: string; - readonly redirectUrl: string; - readonly close: () => void; -} - -export interface DesktopCloudAuthShape { - readonly createRequest: Effect.Effect; - readonly configure: Effect.Effect< - void, - never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope - >; -} - -export class DesktopCloudAuth extends Context.Service()( - "@t3tools/desktop/app/DesktopCloudAuth", -) {} - -export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string { - return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME; -} - -export function buildCloudAuthCallbackUrl(input: { - readonly scheme: string; - readonly state: string; -}): string { - const url = new URL( - `${input.scheme}://${CLOUD_AUTH_CALLBACK_HOST}${CLOUD_AUTH_CALLBACK_PATHNAME}`, - ); - url.searchParams.set(CLOUD_AUTH_CALLBACK_STATE_PARAM, input.state); - return url.toString(); -} - -export function parseCloudAuthCallbackUrl(input: { - readonly rawUrl: unknown; - readonly scheme: string; - readonly state: string; -}): URL | null { - if (typeof input.rawUrl !== "string") { - return null; - } - - try { - const url = new URL(input.rawUrl); - if (url.protocol !== `${input.scheme}:`) return null; - if (url.hostname !== CLOUD_AUTH_CALLBACK_HOST) return null; - if (url.pathname !== CLOUD_AUTH_CALLBACK_PATHNAME) return null; - if (url.searchParams.get(CLOUD_AUTH_CALLBACK_STATE_PARAM) !== input.state) return null; - return url; - } catch { - return null; - } -} - -export function findCloudAuthCallbackUrl(input: { - readonly values: readonly unknown[]; - readonly scheme: string; - readonly state: string; -}): URL | null { - for (const value of input.values) { - const url = parseCloudAuthCallbackUrl({ - rawUrl: value, - scheme: input.scheme, - state: input.state, - }); - if (url) return url; - } - return null; -} - -export function resolveProtocolClientLaunchArgs(input: { - readonly argv: readonly string[]; -}): readonly string[] { - return input.argv.slice(1); -} - -function resolveConfiguredProtocolClient(): { - readonly path: string; - readonly args: readonly string[]; -} | null { - const path = process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_PATH?.trim(); - if (!path) return null; - - return { - path, - args: (process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_ARGS ?? "") - .split("\n") - .map((arg) => arg.trim()) - .filter((arg) => arg.length > 0), - }; -} - -function isProtocolRegistrationManagedExternally(): boolean { - return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1"; -} - -function resolveProtocolCallbackForwardUrl(): URL | null { - const rawUrl = process.env.T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL?.trim(); - if (!rawUrl) return null; - - try { - const url = new URL(rawUrl); - if (url.protocol !== "http:") return null; - if (url.hostname !== "127.0.0.1") return null; - if (url.pathname !== "/auth/callback") return null; - if (!url.port) return null; - return url; - } catch { - return null; - } -} - -const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => { - request?.close(); - return null; -}; - -function createCloudAuthRequestTimeout(onExpire: () => void): ReturnType { - // @effect-diagnostics-next-line globalTimers:off - Auth request expiry is tied to an Electron callback server, not fiber scheduling. - return setTimeout(onExpire, CLOUD_AUTH_REQUEST_TIMEOUT_MS); -} - -function ignoreCloudAuthCallback(_rawUrl: string) {} - -function startProtocolCallbackForwardServer( - callbackUrl: URL, - dispatch: (rawUrl: string) => void, -): Effect.Effect { - const port = Number.parseInt(callbackUrl.port, 10); - const routesLayer = HttpRouter.add( - "POST", - "/auth/callback", - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const rawUrl = yield* request.text; - yield* Effect.sync(() => { - dispatch(rawUrl); - }); - return HttpServerResponse.empty({ status: 204 }); - }), - ); - - return Effect.gen(function* () { - const NodeHttp = yield* Effect.promise(() => import("node:http")); - const serverLayer = NodeHttpServer.layer(NodeHttp.createServer, { - host: callbackUrl.hostname, - port, - }); - yield* Layer.launch(HttpRouter.serve(routesLayer).pipe(Layer.provideMerge(serverLayer))).pipe( - Effect.forkScoped, - ); - }); -} - -const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - let pendingAuthRequest: PendingCloudAuthRequest | null = null; - let dispatchCloudAuthCallback: (rawUrl: string) => void = ignoreCloudAuthCallback; - const makeCloudAuthRequestState = Effect.gen(function* () { - const [left, right] = yield* Effect.all([crypto.randomUUIDv4, crypto.randomUUIDv4]); - return `${left}${right}`.replaceAll("-", ""); - }); - - return DesktopCloudAuth.of({ - createRequest: Effect.gen(function* () { - const scheme = resolveCloudAuthCallbackScheme({ - isDevelopment: environment.isDevelopment, - }); - const state = yield* makeCloudAuthRequestState.pipe( - Effect.mapError((cause) => new DesktopCloudAuthCallbackServerError({ cause })), - ); - - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - - const redirectUrl = buildCloudAuthCallbackUrl({ scheme, state }); - const timeout = createCloudAuthRequestTimeout(() => { - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - }); - pendingAuthRequest = { - state, - redirectUrl, - close: () => clearTimeout(timeout), - }; - return redirectUrl; - }), - configure: Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const scope = yield* Scope.Scope; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - const scheme = resolveCloudAuthCallbackScheme({ - isDevelopment: environment.isDevelopment, - }); - - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - }), - ); - - if (isProtocolRegistrationManagedExternally()) { - // Development macOS launchers set the default URL handler before the stock Electron - // process starts so LaunchServices binds the scheme to the worktree-specific app bundle. - } else if (environment.isDevelopment) { - const configuredClient = resolveConfiguredProtocolClient(); - if (configuredClient) { - yield* electronApp.setAsDefaultProtocolClient( - scheme, - configuredClient.path, - configuredClient.args, - ); - } else { - yield* electronApp.setAsDefaultProtocolClient( - scheme, - process.execPath, - resolveProtocolClientLaunchArgs({ argv: process.argv }), - ); - } - } else { - yield* electronApp.setAsDefaultProtocolClient(scheme); - } - - dispatchCloudAuthCallback = (rawUrl: string) => { - const pending = pendingAuthRequest; - const callbackUrl = pending - ? parseCloudAuthCallbackUrl({ rawUrl, scheme, state: pending.state }) - : null; - if (!callbackUrl) { - return; - } - - pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); - void runPromise( - Effect.gen(function* () { - yield* electronWindow.sendAll( - IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, - callbackUrl.toString(), - ); - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }; - - const protocolCallbackForwardUrl = resolveProtocolCallbackForwardUrl(); - if (environment.isDevelopment && protocolCallbackForwardUrl) { - yield* startProtocolCallbackForwardServer( - protocolCallbackForwardUrl, - dispatchCloudAuthCallback, - ); - } - - const hasInstanceLock = yield* electronApp.requestSingleInstanceLock; - if (!hasInstanceLock) { - return yield* electronApp.quit; - } - - yield* electronApp.on<[Electron.Event, string]>("open-url", (event, rawUrl) => { - event.preventDefault?.(); - dispatchCloudAuthCallback(rawUrl); - }); - - yield* electronApp.on<[Electron.Event, readonly string[]]>( - "second-instance", - (_event, argv) => { - const values = resolveProtocolClientLaunchArgs({ argv }); - const pending = pendingAuthRequest; - const callbackUrl = pending - ? findCloudAuthCallbackUrl({ values, scheme, state: pending.state }) - : null; - if (callbackUrl) { - dispatchCloudAuthCallback(callbackUrl.toString()); - return; - } - - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); - }, - ); - }).pipe(Effect.withSpan("desktop.cloudAuth.configure")), - }); -}); - -export const layer = Layer.effect(DesktopCloudAuth, make); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts deleted file mode 100644 index 3257edca885..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts"; - -const textDecoder = new TextDecoder(); -const textEncoder = new TextEncoder(); - -function makeSafeStorageLayer(input: { readonly available: boolean }) { - return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { - isEncryptionAvailable: Effect.succeed(input.available), - encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)), - decryptString: (value) => { - const decoded = textDecoder.decode(value); - if (!decoded.startsWith("enc:")) { - return Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageDecryptError({ - cause: new Error("invalid encrypted token"), - }), - ); - } - return Effect.succeed(decoded.slice("enc:".length)); - }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); -} - -function makeLayer(baseDir: string, input?: { readonly encryptionAvailable?: boolean }) { - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: baseDir, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), - ), - ); - - return DesktopCloudAuthTokenStore.layer.pipe( - Layer.provideMerge(environmentLayer), - Layer.provideMerge(makeSafeStorageLayer({ available: input?.encryptionAvailable ?? true })), - Layer.provideMerge(NodeServices.layer), - ); -} - -const withTokenStore = ( - effect: Effect.Effect, - input?: { readonly encryptionAvailable?: boolean }, -) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-cloud-auth-token-test-", - }); - return yield* effect.pipe(Effect.provide(makeLayer(baseDir, input))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - -describe("DesktopCloudAuthTokenStore", () => { - it.effect("persists, reads, and clears the encrypted Clerk client JWT", () => - withTokenStore( - Effect.gen(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - - assert.isTrue(yield* tokenStore.set("__client=test.jwt")); - assert.deepStrictEqual(yield* tokenStore.get, Option.some("__client=test.jwt")); - - yield* tokenStore.clear; - assert.deepStrictEqual(yield* tokenStore.get, Option.none()); - }), - ), - ); - - it.effect("does not persist a token when Electron safe storage is unavailable", () => - withTokenStore( - Effect.gen(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - - assert.isFalse(yield* tokenStore.set("__client=test.jwt")); - assert.deepStrictEqual(yield* tokenStore.get, Option.none()); - }), - { encryptionAvailable: false }, - ), - ); -}); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts deleted file mode 100644 index 652072c1f5d..00000000000 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { fromLenientJson } from "@t3tools/shared/schemaJson"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; - -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -interface CloudAuthTokenDocument { - readonly version: number; - readonly encryptedClientJwt: string; -} - -const CloudAuthTokenDocumentSchema = Schema.Struct({ - version: Schema.Number, - encryptedClientJwt: Schema.String, -}); - -const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema); -const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson); -const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson); - -export class DesktopCloudAuthTokenStoreWriteError extends Data.TaggedError( - "DesktopCloudAuthTokenStoreWriteError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop cloud auth token: ${this.cause.message}`; - } -} - -export class DesktopCloudAuthTokenStoreDecodeError extends Data.TaggedError( - "DesktopCloudAuthTokenStoreDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode desktop cloud auth token."; - } -} - -export interface DesktopCloudAuthTokenStoreShape { - readonly get: Effect.Effect< - Option.Option, - | DesktopCloudAuthTokenStoreDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError - >; - readonly set: ( - token: string, - ) => Effect.Effect< - boolean, - | DesktopCloudAuthTokenStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError - >; - readonly clear: Effect.Effect; -} - -export class DesktopCloudAuthTokenStore extends Context.Service< - DesktopCloudAuthTokenStore, - DesktopCloudAuthTokenStoreShape ->()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {} - -function decodeSecretBytes( - encoded: string, -): Effect.Effect { - return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( - Effect.mapError((cause) => new DesktopCloudAuthTokenStoreDecodeError({ cause })), - ); -} - -const readDocument = ( - fileSystem: FileSystem.FileSystem, - tokenPath: string, -): Effect.Effect> => - fileSystem.readFileString(tokenPath).pipe( - Effect.option, - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(Option.none()), - onSome: (raw) => decodeCloudAuthTokenDocumentJson(raw).pipe(Effect.option), - }), - ), - ); - -const writeDocument = Effect.fn("desktop.cloudAuthTokenStore.writeDocument")(function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly tokenPath: string; - readonly document: CloudAuthTokenDocument; - readonly suffix: string; -}): Effect.fn.Return { - const directory = input.path.dirname(input.tokenPath); - const tempPath = `${input.tokenPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeCloudAuthTokenDocumentJson(input.document); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.tokenPath); -}); - -export const layer = Layer.effect( - DesktopCloudAuthTokenStore, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - const tokenPath = path.join(environment.stateDir, "cloud-auth-token.json"); - - return DesktopCloudAuthTokenStore.of({ - get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, tokenPath); - if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - - const secretBytes = yield* decodeSecretBytes(document.value.encryptedClientJwt); - return Option.some(yield* safeStorage.decryptString(secretBytes)); - }).pipe(Effect.withSpan("desktop.cloudAuthTokenStore.get")), - set: Effect.fn("desktop.cloudAuthTokenStore.set")(function* (token) { - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - - const encryptedClientJwt = Encoding.encodeBase64(yield* safeStorage.encryptString(token)); - const suffix = (yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })), - )).replace(/-/g, ""); - yield* writeDocument({ - fileSystem, - path, - tokenPath, - document: { version: 1, encryptedClientJwt }, - suffix, - }).pipe(Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause }))); - return true; - }), - clear: fileSystem.remove(tokenPath, { force: true }).pipe( - Effect.catch(() => Effect.void), - Effect.withSpan("desktop.cloudAuthTokenStore.clear"), - ), - }); - }), -); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts new file mode 100644 index 00000000000..e0be7f39b39 --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -0,0 +1,401 @@ +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, ElectronSafeStorage.ElectronSafeStorageDecryptError); + yield* Ref.set(failDecrypt, false); + assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts new file mode 100644 index 00000000000..8467fe3f077 --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -0,0 +1,435 @@ +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", +]); +type DesktopConnectionCatalogStoreWriteOperation = + typeof DesktopConnectionCatalogStoreWriteOperation.Type; + +const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ + "read-legacy-registry", + "read-legacy-secret", + "encode-catalog", + "persist-catalog", +]); +type DesktopConnectionCatalogStoreMigrationOperation = + typeof DesktopConnectionCatalogStoreMigrationOperation.Type; + +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}.`; + } +} + +const writeError = ( + operation: DesktopConnectionCatalogStoreWriteOperation, + path: string, + cause: unknown, +): DesktopConnectionCatalogStoreWriteError => + new DesktopConnectionCatalogStoreWriteError({ + operation, + path, + cause, + }); + +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}.`; + } +} + +const migrationError = ( + operation: DesktopConnectionCatalogStoreMigrationOperation, + catalogPath: string, + cause: unknown, + environmentId?: string, +): DesktopConnectionCatalogStoreMigrationError => + new DesktopConnectionCatalogStoreMigrationError({ + operation, + catalogPath, + ...(environmentId === undefined ? {} : { environmentId }), + cause, + }); + +export class DesktopConnectionCatalogStore extends Context.Service< + DesktopConnectionCatalogStore, + { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreDecodeError + | DesktopConnectionCatalogStoreMigrationError + | ElectronSafeStorage.ElectronSafeStorageError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError + >; + 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) => writeError("encode-document", input.catalogPath, cause)), + ); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* Effect.gen(function* () { + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.catalogPath) + .pipe( + Effect.mapError((cause) => writeError("replace-catalog-file", 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) => + migrationError("read-legacy-secret", catalogPath, cause, record.environmentId), + ), + ); + 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 writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( + catalog: string, + ) { + const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const suffix = (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => writeError("create-temporary-file-name", catalogPath, cause)), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + catalogPath, + document: { version: 1, encryptedCatalog }, + suffix, + }); + }); + + const migrateLegacyCatalog = Effect.gen(function* () { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + const records = yield* savedEnvironments.getRegistry.pipe( + Effect.mapError((cause) => migrationError("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) => migrationError("encode-catalog", catalogPath, cause)), + ); + yield* writeCatalog(encoded).pipe( + Effect.mapError((cause) => migrationError("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* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( + Effect.flatMap(safeStorage.decryptString), + ); + return Option.some(decrypted); + }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + yield* writeCatalog(catalog); + return true; + }), + clear: fileSystem.remove(catalogPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear the desktop connection catalog.", { + catalogPath, + error, + }), + ), + Effect.withSpan("desktop.connectionCatalogStore.clear"), + ), + }); +}); + +export const layer = Layer.effect(DesktopConnectionCatalogStore, make); diff --git a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts new file mode 100644 index 00000000000..ae78080539b --- /dev/null +++ b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts @@ -0,0 +1,37 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; + +import { DesktopLifecycleRelaunchError } from "./DesktopLifecycle.ts"; +import { DesktopApplicationMenuActionError } from "../window/DesktopApplicationMenu.ts"; + +describe("desktop detached action errors", () => { + it("preserves the complete relaunch failure cause and reason", () => { + const cause = Cause.combine( + Cause.fail(new Error("shutdown failed")), + Cause.die(new Error("relaunch defect")), + ); + const error = new DesktopLifecycleRelaunchError({ + reason: "apply update", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "apply update"); + assert.equal(error.message, 'Desktop relaunch failed for reason "apply update".'); + }); + + it("preserves the complete menu action failure cause and action", () => { + const cause = Cause.combine( + Cause.fail(new Error("window unavailable")), + Cause.die(new Error("dispatch defect")), + ); + const error = new DesktopApplicationMenuActionError({ + action: "open-settings", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.action, "open-settings"); + assert.equal(error.message, 'Desktop menu action "open-settings" failed.'); + }); +}); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 5a6be92ac11..061a9368c53 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,55 +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 browserArtifactsDir: string; - readonly rootDir: string; - readonly appRoot: string; - readonly backendEntryPath: string; - readonly backendCwd: string; - readonly preloadPath: string; - readonly appUpdateYmlPath: string; - readonly devServerUrl: Option.Option; - readonly devRemoteT3ServerEntryPath: Option.Option; - readonly configuredBackendPort: Option.Option; - readonly commitHashOverride: Option.Option; - readonly otlpTracesUrl: Option.Option; - readonly otlpExportIntervalMs: number; - readonly branding: DesktopAppBranding; - readonly displayName: string; - readonly appUserModelId: string; - readonly linuxDesktopEntryName: string; - readonly linuxWmClass: string; - readonly userDataDirName: string; - readonly legacyUserDataDirName: string; - readonly defaultDesktopSettings: DesktopSettings; - readonly runtimeInfo: DesktopRuntimeInfo; - readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; - readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; - readonly developmentDockIconPath: string; -} - export class DesktopEnvironment extends Context.Service< DesktopEnvironment, - DesktopEnvironmentShape + { + readonly path: Path.Path; + readonly dirname: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly isPackaged: boolean; + readonly isDevelopment: boolean; + readonly appVersion: string; + readonly appPath: string; + readonly resourcesPath: string; + readonly homeDirectory: string; + readonly appDataDirectory: string; + readonly baseDir: string; + readonly stateDir: string; + readonly desktopSettingsPath: string; + readonly clientSettingsPath: string; + readonly savedEnvironmentRegistryPath: string; + readonly serverSettingsPath: string; + readonly logDir: string; + readonly browserArtifactsDir: string; + readonly rootDir: string; + readonly appRoot: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly preloadPath: string; + readonly appUpdateYmlPath: string; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; + readonly otlpTracesUrl: Option.Option; + readonly otlpExportIntervalMs: number; + readonly branding: DesktopAppBranding; + readonly displayName: string; + readonly appUserModelId: string; + readonly linuxDesktopEntryName: string; + readonly linuxWmClass: string; + readonly userDataDirName: string; + readonly legacyUserDataDirName: string; + readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; + readonly runtimeInfo: DesktopRuntimeInfo; + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; + readonly developmentDockIconPath: string; + } >()("@t3tools/desktop/app/DesktopEnvironment") {} const APP_BASE_NAME = "T3 Code"; @@ -136,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; @@ -208,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, @@ -250,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 a7957ffca19..c5264332b66 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,75 +1,55 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopObservability from "./DesktopObservability.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; -export interface DesktopShutdownShape { - readonly request: Effect.Effect; - readonly awaitRequest: Effect.Effect; - readonly markComplete: Effect.Effect; - readonly awaitComplete: Effect.Effect; - readonly isComplete: Effect.Effect; +export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( + "DesktopLifecycleRelaunchError", + { + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop relaunch failed for reason "${this.reason}".`; + } } -export class DesktopShutdown extends Context.Service()( - "@t3tools/desktop/app/DesktopLifecycle/DesktopShutdown", -) {} - -const makeShutdown = Effect.gen(function* () { - const requested = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const completedRef = yield* Ref.make(false); - - return DesktopShutdown.of({ - request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), - awaitRequest: Deferred.await(requested), - markComplete: Ref.set(completedRef, true).pipe( - Effect.andThen(Deferred.succeed(completed, undefined)), - Effect.asVoid, - ), - awaitComplete: Deferred.await(completed), - isComplete: Ref.get(completedRef), - }); -}); - -export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); - export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment - | DesktopShutdown + | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp | ElectronTheme.ElectronTheme; -export interface DesktopLifecycleShape { - readonly relaunch: ( - reason: string, - ) => Effect.Effect; - readonly register: Effect.Effect; -} - /** * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme */ -export class DesktopLifecycle extends Context.Service()( - "@t3tools/desktop/app/DesktopLifecycle", -) {} +export class DesktopLifecycle extends Context.Service< + DesktopLifecycle, + { + readonly relaunch: ( + reason: string, + ) => Effect.Effect; + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = - DesktopObservability.makeComponentLogger("desktop-lifecycle"); + makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -93,8 +73,8 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { - const shutdown = yield* DesktopShutdown; + function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; yield* shutdown.awaitComplete; }, @@ -154,83 +134,81 @@ function quitFromSignal( ); } -export const layer = Layer.succeed( - DesktopLifecycle, - DesktopLifecycle.of({ - relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - yield* logLifecycleInfo("desktop relaunch requested", { reason }); - yield* Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); - if (environment.isDevelopment) { - yield* electronApp.exit(75); - return; - } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - yield* electronApp.exit(0); - }).pipe( - Effect.catchCause((cause) => - logLifecycleError("desktop relaunch failed", { - cause: Cause.pretty(cause), - }), - ), - Effect.forkDetach, - Effect.asVoid, - ); - }), - register: Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const electronApp = yield* ElectronApp.ElectronApp; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); - const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - yield* electronTheme.onUpdated(() => { - void runEffect( - desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), - ); - }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed, - () => { - quitAllowed = true; - }, - ); +export const make = DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), }); - yield* electronApp.on("activate", () => { - void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); }); - yield* electronApp.on("window-all-closed", () => { - void runEffect( - Effect.gen(function* () { - const app = yield* ElectronApp.ElectronApp; - const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* app.quit; - } - }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), - ); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), +}); - if (environment.platform !== "win32") { - yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); - }); - yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); - }); - } - }).pipe(Effect.withSpan("desktop.lifecycle.register")), - }), -); +export const layer = Layer.succeed(DesktopLifecycle, make); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 2349fe52dc3..21dd27ba28d 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,52 +1,20 @@ import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import * as Tracer from "effect/Tracer"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -const DESKTOP_LOG_FILE_MAX_FILES = 10; -const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; -export interface RotatingLogFileWriter { - readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; - readonly writeText: (chunk: string) => Effect.Effect; -} - -export interface DesktopBackendOutputLogShape { - readonly writeSessionBoundary: (input: { - readonly phase: "START" | "END"; - readonly details: string; - }) => Effect.Effect; - readonly writeOutputChunk: ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, - ) => Effect.Effect; -} - -export class DesktopBackendOutputLog extends Context.Service< - DesktopBackendOutputLog, - DesktopBackendOutputLogShape ->()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLog") {} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); +export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts"; export type DesktopLogAnnotations = Record; @@ -82,165 +50,7 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { }; } -class DesktopLogFileWriterConfigurationError extends Data.TaggedError( - "DesktopLogFileWriterConfigurationError", -)<{ - readonly option: "maxBytes" | "maxFiles"; - readonly value: number; -}> { - override get message() { - return `${this.option} must be >= 1 (received ${this.value})`; - } -} - -type DesktopLogFileWriterError = - | DesktopLogFileWriterConfigurationError - | PlatformError.PlatformError; - -const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); - -const DesktopBackendChildLogRecord = Schema.Struct({ - message: Schema.String, - level: Schema.Literals(["INFO", "ERROR"]), - timestamp: Schema.String, - annotations: Schema.Record(Schema.String, Schema.Unknown), - spans: Schema.Record(Schema.String, Schema.Unknown), - fiberId: Schema.String, -}); - -const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( - Schema.fromJsonString(DesktopBackendChildLogRecord), -); - -const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { - writeSessionBoundary: () => Effect.void, - writeOutputChunk: () => Effect.void, -}; - -const currentDesktopRunId = Effect.gen(function* () { - const annotations = yield* References.CurrentLogAnnotations; - const runId = annotations.runId; - return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; -}); - -const refreshFileSize = ( - fileSystem: FileSystem.FileSystem, - filePath: string, -): Effect.Effect => - fileSystem.stat(filePath).pipe( - Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), - ); - -const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { - readonly filePath: string; - readonly maxBytes?: number; - readonly maxFiles?: number; -}): Effect.fn.Return< - RotatingLogFileWriter, - DesktopLogFileWriterError, - FileSystem.FileSystem | Path.Path -> { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; - const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; - const directory = path.dirname(input.filePath); - const baseName = path.basename(input.filePath); - - if (maxBytes < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxBytes", - value: maxBytes, - }); - } - if (maxFiles < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxFiles", - value: maxFiles, - }); - } - - yield* fileSystem.makeDirectory(directory, { recursive: true }); - - const withSuffix = (index: number) => `${input.filePath}.${index}`; - const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); - const mutex = yield* Semaphore.make(1); - - const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); - for (const entry of entries) { - if (!entry.startsWith(`${baseName}.`)) continue; - const suffix = Number(entry.slice(baseName.length + 1)); - if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); - } - }); - - const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); - for (let index = maxFiles - 1; index >= 1; index -= 1) { - const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); - if (sourceExists) { - yield* fileSystem.rename(source, withSuffix(index + 1)); - } - } - const currentExists = yield* fileSystem - .exists(input.filePath) - .pipe(Effect.orElseSucceed(() => false)); - if (currentExists) { - yield* fileSystem.rename(input.filePath, withSuffix(1)); - } - yield* Ref.set(currentSize, 0); - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ); - - const writeBytes = (chunk: Uint8Array): Effect.Effect => { - if (chunk.byteLength === 0) return Effect.void; - - return mutex.withPermits(1)( - Effect.gen(function* () { - const beforeSize = yield* Ref.get(currentSize); - if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { - yield* rotate; - } - - yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); - const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; - yield* Ref.set(currentSize, afterSize); - - if (afterSize > maxBytes) { - yield* rotate; - } - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ), - ); - }; - - yield* pruneOverflowBackups; - - return { - writeBytes, - writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), - } satisfies RotatingLogFileWriter; -}); - -const readPersistedOtlpTracesUrl: Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> = Effect.gen(function* () { +const readPersistedOtlpTracesUrl = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); @@ -260,90 +70,6 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { return yield* readPersistedOtlpTracesUrl; }); -const writeDevelopmentConsoleOutput = ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, -): Effect.Effect => - Effect.sync(() => { - const output = streamName === "stderr" ? process.stderr : process.stdout; - output.write(chunk); - }).pipe(Effect.ignore); - -const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( - function* ( - logFile: RotatingLogFileWriter, - input: { - readonly message: string; - readonly level: "INFO" | "ERROR"; - readonly annotations: Record; - }, - ): Effect.fn.Return { - return yield* Effect.gen(function* () { - const timestamp = DateTime.formatIso(yield* DateTime.now); - const encoded = yield* encodeDesktopBackendChildLogRecord({ - message: input.message, - level: input.level, - timestamp, - annotations: input.annotations, - spans: {}, - fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); - }, -); - -const backendOutputLogLayer = Layer.effect( - DesktopBackendOutputLog, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - - const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); - - return Option.match(writer, { - onNone: () => DesktopBackendOutputLogNoop, - onSome: (logFile) => - ({ - writeSessionBoundary: Effect.fn( - "desktop.observability.backendOutput.writeSessionBoundary", - )(function* ({ phase, details }) { - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: `backend child process session ${phase.toLowerCase()}`, - level: "INFO", - annotations: { - component: "desktop-backend-child", - runId, - phase, - details: sanitizeLogValue(details), - }, - }); - }), - writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( - function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId, - stream: streamName, - text: textDecoder.decode(chunk), - }, - }); - }, - ), - }) satisfies DesktopBackendOutputLogShape, - }); - }), -); - const desktopLoggerLayer = Layer.mergeAll( Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), Layer.succeed(References.MinimumLogLevel, "Info"), @@ -356,8 +82,8 @@ const tracerLayer = Layer.unwrap( const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); const sink = yield* makeTraceSink({ filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, }); const delegate = Option.isNone(otlpTracesUrl) @@ -375,8 +101,8 @@ const tracerLayer = Layer.unwrap( }); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, sink, ...(delegate ? { delegate } : {}), @@ -387,7 +113,7 @@ const tracerLayer = Layer.unwrap( ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); export const layer = Layer.mergeAll( - backendOutputLogLayer, + DesktopBackendOutputLogModule.layer, desktopLoggerLayer, tracerLayer, Layer.succeed(Tracer.MinimumTraceLevel, "Info"), diff --git a/apps/desktop/src/app/DesktopShutdown.ts b/apps/desktop/src/app/DesktopShutdown.ts new file mode 100644 index 00000000000..78b77b565b9 --- /dev/null +++ b/apps/desktop/src/app/DesktopShutdown.ts @@ -0,0 +1,35 @@ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export class DesktopShutdown extends Context.Service< + DesktopShutdown, + { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopShutdown") {} + +const make = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); + +export const layer = Layer.effect(DesktopShutdown, make); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index f325c99d229..cd2abe91065 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -3,19 +3,17 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -export interface DesktopStateShape { - readonly backendReady: Ref.Ref; - readonly quitting: Ref.Ref; -} +export class DesktopState extends Context.Service< + DesktopState, + { + readonly backendReady: Ref.Ref; + readonly quitting: Ref.Ref; + } +>()("@t3tools/desktop/app/DesktopState") {} -export class DesktopState extends Context.Service()( - "@t3tools/desktop/app/DesktopState", -) {} +const make = Effect.all({ + backendReady: Ref.make(false), + quitting: Ref.make(false), +}); -export const layer = Layer.effect( - DesktopState, - Effect.all({ - backendReady: Ref.make(false), - quitting: Ref.make(false), - }), -); +export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..43e77a0c4cb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -3,6 +3,8 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -21,6 +23,10 @@ const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), ); +const isDesktopBackendObservabilitySettingsReadError = Schema.is( + DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, +); + const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ @@ -34,7 +40,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), -} satisfies DesktopServerExposure.DesktopServerExposureShape); +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); function makeEnvironmentLayer( baseDir: string, @@ -166,6 +172,62 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("logs structured context when persisted observability settings cannot be read", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const settingsPath = `${baseDir}/userdata/settings.json`; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: settingsPath, + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const failingFileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(cause), + }), + ); + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolve; + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + Layer.provideMerge(failingFileSystemLayer), + ), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + + const error = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .find(isDesktopBackendObservabilitySettingsReadError); + assert.isDefined(error); + assert.equal(error.settingsPath, settingsPath); + assert.equal(error.cause, cause); + assert.equal( + error.message, + `Failed to read persisted backend observability settings at ${settingsPath}.`, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("captures backend output in development so child process logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 5e4e034b5e7..d8bd1a13dcb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -8,22 +8,32 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -export interface DesktopBackendConfigurationShape { - readonly resolve: Effect.Effect< - DesktopBackendManager.DesktopBackendStartConfig, - PlatformError.PlatformError - >; +export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( + "DesktopBackendObservabilitySettingsReadError", + { + settingsPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read persisted backend observability settings at ${this.settingsPath}.`; + } } export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, - DesktopBackendConfigurationShape + { + readonly resolve: Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} interface BackendObservabilitySettings { @@ -52,29 +62,34 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); -const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( - "desktop-backend-configuration", -); +const logBackendObservabilitySettingsReadFailure = ( + settingsPath: string, + cause: PlatformError.PlatformError, +) => { + const error = new DesktopBackendObservabilitySettingsReadError({ settingsPath, cause }); + return Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-backend-configuration", + error, + }), + ); +}; -const readPersistedBackendObservabilitySettings: Effect.Effect< - BackendObservabilitySettings, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> = Effect.gen(function* () { +const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const exists = yield* fileSystem - .exists(environment.serverSettingsPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return emptyBackendObservabilitySettings; - } - - const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( + Effect.as(Option.none()), + ), + }), + ); if (Option.isNone(raw)) { - yield* logBackendConfigurationWarning( - "failed to read persisted backend observability settings", - ); return emptyBackendObservabilitySettings; } @@ -130,40 +145,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv }, ); -export const layer = Layer.effect( - DesktopBackendConfiguration, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const crypto = yield* Crypto.Crypto; - const tokenRef = yield* Ref.make(Option.none()); - const getOrCreateBootstrapToken = Effect.gen(function* () { - const existing = yield* Ref.get(tokenRef); - if (Option.isSome(existing)) { - return existing.value; - } - - const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); - yield* Ref.set(tokenRef, Option.some(token)); - return token; - }); - - return DesktopBackendConfiguration.of({ - resolve: Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - return yield* resolveBackendStartConfig({ - bootstrapToken, - observabilitySettings, - }).pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), - ); - }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), - }); - }), -); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const crypto = yield* Crypto.Crypto; + const tokenRef = yield* Ref.make(Option.none()); + const getOrCreateBootstrapToken = Effect.gen(function* () { + const existing = yield* Ref.get(tokenRef); + if (Option.isSome(existing)) { + return existing.value; + } + + const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); + yield* Ref.set(tokenRef, Option.some(token)); + return token; + }); + + return DesktopBackendConfiguration.of({ + resolve: Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken; + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolveBackendStartConfig({ + bootstrapToken, + observabilitySettings, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + }); +}); + +export const layer = Layer.effect(DesktopBackendConfiguration, make); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 6c5109c8714..4a88be8838a 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -104,9 +104,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 +127,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,7 +138,7 @@ function makeManagerLayer(input: { dispatchMenuAction: () => Effect.void, syncAppearance: Effect.void, ...input.desktopWindow, - } satisfies DesktopWindow.DesktopWindowShape), + } satisfies DesktopWindow.DesktopWindow["Service"]), ), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 07693a82707..bc47cab37d7 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"; @@ -16,8 +15,9 @@ 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, @@ -59,29 +59,38 @@ interface BackendProcessExit { readonly result: Result.Result; } -export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ - readonly url: URL; -}> { - override get message() { +export class BackendTimeoutError extends Schema.TaggedErrorClass()( + "BackendTimeoutError", + { + url: Schema.URL, + }, +) { + override get message(): string { return `Timed out waiting for backend readiness at ${this.url.href}.`; } } -class BackendProcessBootstrapEncodeError extends Data.TaggedError( +class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", -)<{ - readonly cause: Schema.SchemaError; -}> { - override get message() { - return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; + { + detail: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode desktop backend bootstrap payload: ${this.detail}`; } } -class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ - readonly cause: PlatformError.PlatformError; -}> { - override get message() { - return `Failed to spawn desktop backend process: ${this.cause.message}`; +class BackendProcessSpawnError extends Schema.TaggedErrorClass()( + "BackendProcessSpawnError", + { + detail: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn desktop backend process: ${this.detail}`; } } @@ -106,16 +115,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 } = @@ -230,7 +237,13 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ): 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({ + detail: cause.message, + cause, + }), + ), ); const onOutput = options.onOutput ?? (() => Effect.void); const command = ChildProcess.make( @@ -256,9 +269,15 @@ 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({ + detail: cause.message, + cause, + }), + ), + ); yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { @@ -277,7 +296,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( return describeProcessExit(yield* Effect.result(handle.exitCode)); }); -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; @@ -603,4 +622,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); -export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); +export const layer = Layer.effect(DesktopBackendManager, make); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts new file mode 100644 index 00000000000..cd54c46c89a --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; + +const config: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: {}, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "desktop-bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +describe("DesktopLocalEnvironmentAuth", () => { + it.effect("exchanges the desktop bootstrap credential only once", () => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.update(requestCount, (count) => count + 1).pipe( + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ), + ), + ); + const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.some(config)), + snapshot: Effect.succeed({ + desiredRunning: true, + ready: true, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + }); + const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(managerLayer, httpClientLayer)), + ); + + const [first, second] = yield* Effect.gen(function* () { + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]); + }).pipe(Effect.provide(testLayer)); + + assert.strictEqual(first, "desktop-bearer-token"); + assert.strictEqual(second, "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 1); + }), + ); +}); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts new file mode 100644 index 00000000000..e619b330d83 --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -0,0 +1,88 @@ +import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; + +export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthBackendNotConfiguredError", + {}, +) { + override get message(): string { + return "Local backend is not configured."; + } +} + +export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthSessionBootstrapError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to create the local desktop bearer session."; + } +} + +export const DesktopLocalEnvironmentAuthError = Schema.Union([ + DesktopLocalEnvironmentAuthBackendNotConfiguredError, + DesktopLocalEnvironmentAuthSessionBootstrapError, +]); +export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type; + +export class DesktopLocalEnvironmentAuth extends Context.Service< + DesktopLocalEnvironmentAuth, + { + readonly getBearerToken: Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} + +export const make = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const httpClient = yield* HttpClient.HttpClient; + const tokenRef = yield* Ref.make(Option.none()); + const mutex = yield* Semaphore.make(1); + + const getBearerToken = mutex + .withPermits(1)( + Effect.gen(function* () { + const cached = yield* Ref.get(tokenRef); + if (Option.isSome(cached)) { + return cached.value; + } + + const configOption = yield* backendManager.currentConfig; + if (Option.isNone(configOption)) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const config = configOption.value; + const session = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: config.httpBaseUrl.href, + credential: config.bootstrap.desktopBootstrapToken, + clientMetadata: { + label: "T3 Code Desktop", + deviceType: "desktop", + }, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.mapError( + (cause) => + new DesktopLocalEnvironmentAuthSessionBootstrapError({ + cause, + }), + ), + ); + yield* Ref.set(tokenRef, Option.some(session.access_token)); + return session.access_token; + }), + ) + .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); + + return DesktopLocalEnvironmentAuth.of({ getBearerToken }); +}); + +export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts new file mode 100644 index 00000000000..411af7553f9 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { networkInterfacesMock } = vi.hoisted(() => ({ + networkInterfacesMock: vi.fn(), +})); + +vi.mock("node:os", () => ({ + networkInterfaces: networkInterfacesMock, +})); + +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; + +const TestLayer = DesktopNetworkInterfaces.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +describe("DesktopNetworkInterfaces", () => { + beforeEach(() => { + networkInterfacesMock.mockReset(); + }); + + it.effect("reads network interfaces through the service", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.10", + family: "IPv4", + internal: false, + }, + ], + }; + networkInterfacesMock.mockReturnValueOnce(interfaces); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + assert.strictEqual(yield* service.read, interfaces); + }).pipe(Effect.provide(TestLayer)); + }); + + it.effect("preserves network interface read failures as structured defects", () => { + const cause = new Error("network interface probe failed"); + networkInterfacesMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + const exit = yield* Effect.exit(service.read); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopNetworkInterfaces.DesktopNetworkInterfacesReadError); + assert.equal(error.platform, "linux"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read desktop network interfaces on linux."); + } + }).pipe(Effect.provide(TestLayer)); + }); +}); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts new file mode 100644 index 00000000000..43f634c4491 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -0,0 +1,52 @@ +import * as NodeOS from "node:os"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type NetworkInterfaces = Readonly< + Record +>; + +export class DesktopNetworkInterfacesReadError extends Schema.TaggedErrorClass()( + "DesktopNetworkInterfacesReadError", + { + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop network interfaces on ${this.platform}.`; + } +} + +export class DesktopNetworkInterfaces extends Context.Service< + DesktopNetworkInterfaces, + { + readonly read: Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return DesktopNetworkInterfaces.of({ + read: Effect.try({ + try: () => NodeOS.networkInterfaces(), + catch: (cause) => new DesktopNetworkInterfacesReadError({ platform, cause }), + }).pipe(Effect.orDie), + }); +}); + +export const layer = Layer.effect(DesktopNetworkInterfaces, make); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index e5fbb84c8ad..8b934fd8d85 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -7,21 +7,18 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - DesktopEnvironment, - layer as makeDesktopEnvironmentLayer, -} from "../app/DesktopEnvironment.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); -const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; -const lanNetworkInterfaces: DesktopNetworkInterfaces = { +const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { en0: [ { address: "192.168.1.20", @@ -31,7 +28,7 @@ const lanNetworkInterfaces: DesktopNetworkInterfaces = { ], }; -const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { +const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { tailscale0: [ { address: "100.90.1.2", @@ -72,7 +69,7 @@ function dieOnSpawnLayer() { } function makeEnvironmentLayer(baseDir: string, env: Record = {}) { - return makeDesktopEnvironmentLayer({ + return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, platform: "darwin", @@ -91,18 +88,19 @@ function makeEnvironmentLayer(baseDir: string, env: Record; readonly spawnerLayer?: Layer.Layer; + readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); - const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), }); return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), @@ -113,18 +111,19 @@ function makeLayer(input: { } const withHarness = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, effect: Effect.Effect< A, E, | R - | DesktopEnvironment + | DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | DesktopServerExposure.DesktopServerExposure | DesktopAppSettings.DesktopAppSettings >, env: Record = {}, spawnerLayer?: Layer.Layer, + desktopSettingsLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -138,6 +137,7 @@ const withHarness = ( networkInterfaces, env, ...(spawnerLayer ? { spawnerLayer } : {}), + ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), ); @@ -240,6 +240,67 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("preserves persistence request context and the settings failure chain", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/desktop-settings.json", + cause: diskFailure, + }); + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.fail(settingsFailure), + setTailscaleServe: () => Effect.fail(settingsFailure), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + return withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.instanceOf( + modeError, + DesktopServerExposure.DesktopServerExposureModePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); + assert.equal(modeError.mode, "network-accessible"); + assert.strictEqual(modeError.cause, settingsFailure); + assert.strictEqual(modeError.cause.cause, diskFailure); + assert.equal( + modeError.message, + "Failed to persist desktop server exposure mode network-accessible.", + ); + assert.notInclude(modeError.message, diskFailure.message); + + const tailscaleError = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 8443 }) + .pipe(Effect.flip); + assert.instanceOf( + tailscaleError, + DesktopServerExposure.DesktopTailscaleServePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); + assert.equal(tailscaleError.enabled, true); + assert.equal(tailscaleError.port, 8443); + assert.strictEqual(tailscaleError.cause, settingsFailure); + assert.strictEqual(tailscaleError.cause.cause, diskFailure); + assert.equal( + tailscaleError.message, + "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", + ); + assert.notInclude(tailscaleError.message, diskFailure.message); + }), + {}, + undefined, + settingsLayer, + ); + }); + it.effect("resolves advertised endpoints from the scoped runtime state", () => withHarness( { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 8b62323499e..f04d2af7b1f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -1,50 +1,35 @@ -import * as NodeOS from "node:os"; - import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, } from "@t3tools/shared/advertisedEndpoint"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, - DesktopServerExposureState, +import { + DesktopServerExposureModeSchema, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, + type DesktopServerExposureMode, + type DesktopServerExposureState, } from "@t3tools/contracts"; +import { readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; -import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; -export interface DesktopNetworkInterfaceInfo { - readonly address: string; - readonly family: string | number; - readonly internal: boolean; - readonly netmask?: string; - readonly mac?: string; - readonly cidr?: string | null; - readonly scopeid?: number; -} - -export type DesktopNetworkInterfaces = Readonly< - Record ->; - interface ResolvedDesktopServerExposure { readonly mode: DesktopServerExposureMode; readonly bindHost: string; @@ -91,7 +76,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { }; const resolveLanAdvertisedHost = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, ): string | null => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); @@ -116,7 +101,7 @@ const resolveLanAdvertisedHost = ( const resolveDesktopServerExposure = (input: { readonly mode: DesktopServerExposureMode; readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride?: string; }): ResolvedDesktopServerExposure => { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; @@ -218,34 +203,56 @@ const resolveDesktopCoreAdvertisedEndpoints = ( return endpoints; }; -type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; - -export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( +export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass()( "DesktopServerExposureNoNetworkAddressError", -)<{ - readonly port: number; -}> { - override get message() { + { + port: Schema.Number, + }, +) { + override get message(): string { return `No reachable network address is available for desktop network access on port ${this.port}.`; } } -export class DesktopServerExposurePersistenceError extends Data.TaggedError( - "DesktopServerExposurePersistenceError", -)<{ - readonly operation: DesktopServerExposurePersistenceOperation; - readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; -}> { - override get message() { - return `Failed to persist desktop ${this.operation} settings.`; +export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass()( + "DesktopServerExposureModePersistenceError", + { + mode: DesktopServerExposureModeSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop server exposure mode ${this.mode}.`; } } -export type DesktopServerExposureSetModeError = - | DesktopServerExposureNoNetworkAddressError - | DesktopServerExposurePersistenceError; +export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorClass()( + "DesktopTailscaleServePersistenceError", + { + enabled: Schema.Boolean, + port: Schema.NullOr(Schema.Number), + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`; + } +} -export type DesktopServerExposureError = DesktopServerExposureSetModeError; +export const DesktopServerExposureSetModeError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, +]); +export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; +export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); + +export const DesktopServerExposureError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, + DesktopTailscaleServePersistenceError, +]); +export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; +export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -260,36 +267,25 @@ export interface DesktopServerExposureChange { readonly requiresRelaunch: boolean; } -export interface DesktopServerExposureShape { - readonly getState: Effect.Effect; - readonly backendConfig: Effect.Effect; - readonly configureFromSettings: (input: { - readonly port: number; - }) => Effect.Effect; - readonly setMode: ( - mode: DesktopServerExposureMode, - ) => Effect.Effect; - readonly setTailscaleServeEnabled: (input: { - readonly enabled: boolean; - readonly port?: number; - }) => Effect.Effect; - readonly getAdvertisedEndpoints: Effect.Effect; -} - export class DesktopServerExposure extends Context.Service< DesktopServerExposure, - DesktopServerExposureShape + { + readonly getState: Effect.Effect; + readonly backendConfig: Effect.Effect; + readonly configureFromSettings: (input: { + readonly port: number; + }) => Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: (input: { + readonly enabled: boolean; + readonly port?: number; + }) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; + } >()("@t3tools/desktop/backend/DesktopServerExposure") {} -export interface DesktopNetworkInterfacesServiceShape { - readonly read: Effect.Effect; -} - -export class DesktopNetworkInterfacesService extends Context.Service< - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesServiceShape ->()("@t3tools/desktop/backend/DesktopServerExposure/DesktopNetworkInterfacesService") {} - interface RuntimeState { readonly requestedMode: DesktopServerExposureMode; readonly mode: DesktopServerExposureMode; @@ -311,10 +307,10 @@ interface ResolvedRuntimeState { const initialRuntimeState = (): RuntimeState => runtimeStateFromResolvedExposure({ - requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - settings: DEFAULT_DESKTOP_SETTINGS, + requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, exposure: resolveDesktopServerExposure({ - mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, port: 0, networkInterfaces: {}, }), @@ -348,7 +344,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure function runtimeStateFromResolvedExposure(input: { readonly requestedMode: DesktopServerExposureMode; - readonly settings: DesktopSettings; + readonly settings: DesktopAppSettings.DesktopSettings; readonly exposure: ResolvedDesktopServerExposure; readonly port: number; }): RuntimeState { @@ -369,9 +365,9 @@ function runtimeStateFromResolvedExposure(input: { function resolveRuntimeState(input: { readonly requestedMode: DesktopServerExposureMode; - readonly settings: DesktopSettings; + readonly settings: DesktopAppSettings.DesktopSettings; readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; readonly advertisedHostOverride: Option.Option; }): ResolvedRuntimeState { const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); @@ -408,12 +404,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; - const networkInterfaces = yield* DesktopNetworkInterfacesService; + const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App @@ -476,8 +472,8 @@ const make = Effect.gen(function* () { const change = yield* desktopSettings.setServerExposureMode(mode).pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "server-exposure-mode", + new DesktopServerExposureModePersistenceError({ + mode, cause, }), ), @@ -504,8 +500,9 @@ const make = Effect.gen(function* () { .pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", + new DesktopTailscaleServePersistenceError({ + enabled: input.enabled, + port: input.port ?? null, cause, }), ), @@ -564,10 +561,3 @@ const make = Effect.gen(function* () { }); export const layer = Layer.effect(DesktopServerExposure, make); - -export const networkInterfacesLayer = Layer.succeed( - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesService.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), - }), -); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 50706923fb3..0b48adc308c 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -9,10 +9,10 @@ import { } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; +import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; @@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: NetworkInterfaces; }): readonly AdvertisedEndpoint[] { const seen = new Set(); const endpoints: AdvertisedEndpoint[] = []; @@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly port: number; readonly serveEnabled?: boolean; readonly servePort?: number; - readonly networkInterfaces: DesktopNetworkInterfaces; + readonly networkInterfaces: NetworkInterfaces; readonly statusJson?: string | null; readonly readMagicDnsName?: Effect.Effect< string | null, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f6ed5cb1df7..f3ce3b4b5f4 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -100,6 +100,44 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reports which app metadata property failed", () => + Effect.gen(function* () { + const cause = new Error("version unavailable"); + getVersionMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.metadata.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError); + assert.strictEqual(error.property, "app-version"); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + 'Failed to read Electron app metadata property "app-version".', + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("preserves Electron readiness failures", () => + Effect.gen(function* () { + const cause = new Error("ready failed"); + whenReadyMock.mockRejectedValueOnce(cause); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.whenReady.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError); + assert.strictEqual(error.isPackaged, true); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + "Failed to wait for the Electron app to become ready (packaged: true).", + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("scopes app event listeners", () => Effect.gen(function* () { const listener = vi.fn(); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 49b432fd5dd..0af8691f6c4 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -13,41 +14,64 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } -export interface ElectronAppShape { - readonly metadata: Effect.Effect; - readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; - readonly quit: Effect.Effect; - readonly exit: (code: number) => Effect.Effect; - readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; - readonly setPath: ( - name: Parameters[0], - path: string, - ) => Effect.Effect; - readonly setName: (name: string) => Effect.Effect; - readonly setAboutPanelOptions: ( - options: Electron.AboutPanelOptionsOptions, - ) => Effect.Effect; - readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; - readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; - readonly setAsDefaultProtocolClient: ( - protocol: string, - path?: string, - args?: readonly string[], - ) => Effect.Effect; - readonly setDesktopName: (desktopName: string) => Effect.Effect; - readonly setDockIcon: (iconPath: string) => Effect.Effect; - readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; +export class ElectronAppMetadataReadError extends Schema.TaggedErrorClass()( + "ElectronAppMetadataReadError", + { + property: Schema.Literals(["app-version", "app-path"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Electron app metadata property "${this.property}".`; + } } -export class ElectronApp extends Context.Service()( - "@t3tools/desktop/electron/ElectronApp", -) {} +export class ElectronAppWhenReadyError extends Schema.TaggedErrorClass()( + "ElectronAppWhenReadyError", + { + isPackaged: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to wait for the Electron app to become ready (packaged: ${this.isPackaged}).`; + } +} + +export class ElectronApp extends Context.Service< + ElectronApp, + { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; + readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; + readonly setAsDefaultProtocolClient: ( + protocol: string, + path?: string, + args?: readonly string[], + ) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronApp") {} const addScopedAppListener = >( eventName: string, @@ -63,16 +87,41 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -const make = ElectronApp.of({ - metadata: Effect.sync(() => ({ - appVersion: Electron.app.getVersion(), - appPath: Electron.app.getAppPath(), - isPackaged: Electron.app.isPackaged, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, - })), +export const make = ElectronApp.of({ + metadata: Effect.gen(function* () { + const appVersion = yield* Effect.try({ + try: () => Electron.app.getVersion(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-version", + cause, + }), + }); + const appPath = yield* Effect.try({ + try: () => Electron.app.getAppPath(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-path", + cause, + }), + }); + + return { + appVersion, + appPath, + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + }; + }), name: Effect.sync(() => Electron.app.name), - whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + whenReady: Effect.gen(function* () { + const isPackaged = Electron.app.isPackaged; + yield* Effect.tryPromise({ + try: () => Electron.app.whenReady(), + catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }), + }); + }), quit: Effect.sync(() => { Electron.app.quit(); }), diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 74e6ae58848..057817ec7e6 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -17,22 +17,21 @@ 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>; + 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 openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index cb25043ff44..d9eb3b22eff 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,11 +1,11 @@ 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 Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -23,19 +23,18 @@ 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; -} - -export class ElectronMenu extends Context.Service()( - "@t3tools/desktop/electron/ElectronMenu", -) {} +export class ElectronMenu extends Context.Service< + ElectronMenu, + { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronMenu") {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; @@ -80,113 +79,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.effect( - ElectronMenu, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - let destructiveMenuIconCache: Option.Option | undefined; +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + icon.setTemplateImage(true); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } - return destructiveMenuIconCache; - }; + return destructiveMenuIconCache; + }; - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } - } + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } - template.push(itemOption); + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } } - return template; - }; + template.push(itemOption); + } - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { return; } + completed = true; + 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); + }), + }); +}); - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { - return; - } - completed = true; - 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); - }), - }); - }), -); +export const layer = Layer.effect(ElectronMenu, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2306c101c63..56fe009fee2 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,105 +1,187 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = - vi.hoisted(() => ({ - registerFileProtocolMock: vi.fn(), - registerSchemesAsPrivilegedMock: vi.fn(), - unregisterProtocolMock: vi.fn(), - })); +const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + netFetchMock: vi.fn(), + unhandleMock: vi.fn(), +})); vi.mock("electron", () => ({ - protocol: { - registerFileProtocol: registerFileProtocolMock, - registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, - unregisterProtocol: unregisterProtocolMock, - }, + net: { fetch: netFetchMock }, + protocol: { handle: handleMock, unhandle: unhandleMock }, })); import * as ElectronProtocol from "./ElectronProtocol.ts"; describe("ElectronProtocol", () => { beforeEach(() => { - registerFileProtocolMock.mockReset(); - registerSchemesAsPrivilegedMock.mockReset(); - unregisterProtocolMock.mockReset(); + handleMock.mockReset(); + netFetchMock.mockReset(); + unhandleMock.mockReset(); }); - it("normalizes safe desktop protocol pathnames", () => { - assert.equal( - Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), - "settings/general", - ); - assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); - }); + it.effect("proxies the stable renderer origin to the current app server", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response("ok")); - it.effect("registers desktop scheme privileges through a layer", () => - Effect.scoped( - Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( - Effect.andThen( - Effect.sync(() => { - assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ - [ - [ - { - scheme: "t3", - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, - ], - ], - ]); - }), - ), - ), - ), + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: "clerk.t3.codes", + }); + assert.isDefined(handler); + + const response = yield* Effect.promise(() => + handler!(new Request("t3code-dev://app/api/health?verbose=1")), + ); + assert.equal(yield* Effect.promise(() => response.text()), "ok"); + assert.include( + response.headers.get("content-security-policy") ?? "", + "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "connect-src 'self' http: https: ws: wss:", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "img-src 'self' t3code-dev: blob: data: http: https:", + ); + assert.include( + response.headers.get("content-security-policy") ?? "", + "font-src 'self' t3code-dev: data:", + ); + }), + ); + + assert.deepEqual( + handleMock.mock.calls.map((call) => call[0]), + ["t3code-dev"], + ); + assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); + assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); + }).pipe(Effect.provide(ElectronProtocol.layer)), ); - it.effect("scopes registered file protocols", () => + it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { - let capturedHandler: - | (( - request: Electron.ProtocolRequest, - callback: (response: Electron.ProtocolResponse) => void, - ) => void) - | undefined; - - registerFileProtocolMock.mockImplementation((_scheme, handler) => { - capturedHandler = handler; - return true; + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; }); const response = yield* Effect.scoped( Effect.gen(function* () { - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - yield* electronProtocol.registerFileProtocol({ - scheme: "t3", - handler: () => Effect.succeed({ path: "/app/index.html" }), - }); - - assert.isDefined(capturedHandler); - return yield* Effect.callback((resume) => { - capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => - resume(Effect.succeed(response)), - ); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, }); + return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); }), ); - assert.deepEqual(response, { path: "/app/index.html" }); - assert.deepEqual( - registerFileProtocolMock.mock.calls.map((call) => call[0]), - ["t3"], + assert.equal(response.status, 404); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("preserves protocol registration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol registration failed"); + handleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const error = yield* Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: undefined, + }), + ).pipe(Effect.flip); + + assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); + assert.equal(error.scheme, "t3code-dev"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("preserves protocol unregistration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol unregistration failed"); + unhandleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const exit = yield* Effect.exit( + Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, + }), + ), ); - assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); + assert.equal(error.scheme, "t3code"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); + } }).pipe(Effect.provide(ElectronProtocol.layer)), ); + + it("keeps executable sources host-restricted while allowing runtime network resources", () => { + const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: "clerk.t3.codes", + }); + const directives = Object.fromEntries( + policy.split("; ").map((directive) => { + const [name, ...sources] = directive.split(" "); + return [name, sources]; + }), + ); + + assert.deepEqual(directives["script-src"], [ + "'self'", + "'unsafe-inline'", + "https://clerk.t3.codes", + "https://challenges.cloudflare.com", + ]); + assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); + assert.deepEqual(directives["img-src"], [ + "'self'", + "t3code:", + "blob:", + "data:", + "http:", + "https:", + ]); + assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); + }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index a56e442ddcb..757c26178d0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,272 +1,163 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; -import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; +export const DESKTOP_HOST = "app"; +export const DESKTOP_PRODUCTION_SCHEME = "t3code"; +export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; -export const DESKTOP_SCHEME = "t3"; - -export class ElectronProtocolRegistrationError extends Data.TaggedError( - "ElectronProtocolRegistrationError", -)<{ - readonly scheme: string; - readonly cause: unknown; -}> { - override get message() { - return `Failed to register ${this.scheme}: file protocol.`; - } +export function getDesktopScheme(isDevelopment: boolean): string { + return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; } -export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( - "ElectronProtocolStaticBundleMissingError", -)<{}> { - override get message() { - return "Desktop static bundle missing. Build apps/server (with bundled client) first."; - } +export function getDesktopOrigin(isDevelopment: boolean): string { + return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`; } -export interface ElectronProtocolShape { - readonly registerFileProtocol: (input: { - readonly scheme: string; - readonly handler: ( - request: Electron.ProtocolRequest, - ) => Effect.Effect; - readonly onFailure?: ( - request: Electron.ProtocolRequest, - cause: Cause.Cause, - ) => Electron.ProtocolResponse; - }) => Effect.Effect; - readonly registerDesktopFileProtocol: Effect.Effect< - void, - ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, - FileSystem.FileSystem | DesktopEnvironment | Scope.Scope - >; +export function getDesktopUrl(isDevelopment: boolean): string { + return `${getDesktopOrigin(isDevelopment)}/`; } -export class ElectronProtocol extends Context.Service()( - "@t3tools/desktop/electron/ElectronProtocol", -) {} - -export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { - const segments: string[] = []; - for (const segment of rawPath.split("/")) { - if (segment.length === 0 || segment === ".") { - continue; - } - if (segment === "..") { - return Option.none(); - } - segments.push(segment); +export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolRegistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register Electron protocol scheme "${this.scheme}".`; } - return Option.some(segments.join("/")); } -const registerDesktopSchemePrivileges = Effect.sync(() => { - Electron.protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, - ]); -}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); - -export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); - -const resolveDesktopStaticDir: Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment -> = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidates = [ - environment.path.join(environment.appRoot, "apps/server/dist/client"), - environment.path.join(environment.appRoot, "apps/web/dist"), - ]; - for (const candidate of candidates) { - const hasIndex = yield* fileSystem - .exists(environment.path.join(candidate, "index.html")) - .pipe(Effect.orElseSucceed(() => false)); - if (hasIndex) { - return Option.some(candidate); - } +export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolUnregistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister Electron protocol scheme "${this.scheme}".`; } - return Option.none(); -}); +} -const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( - function* ( - staticRoot: string, - requestUrl: string, - ): Effect.fn.Return { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = normalizeDesktopProtocolPathname(rawPath); - if (Option.isNone(normalizedPath)) { - return environment.path.join(staticRoot, "index.html"); - } +export interface DesktopProtocolRegistrationInput { + readonly scheme: string; + readonly targetOrigin: URL; + readonly backendOrigin: URL; + readonly clerkFrontendApiHostname: string | undefined; +} - const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; - const resolvedPath = environment.path.join(staticRoot, requestedPath); +export class ElectronProtocol extends Context.Service< + ElectronProtocol, + { + readonly registerDesktopProtocol: ( + input: DesktopProtocolRegistrationInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronProtocol") {} + +export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { + const clerkOrigin = input.clerkFrontendApiHostname + ? `https://${input.clerkFrontendApiHostname}` + : undefined; + const scriptSources = [ + "'self'", + "'unsafe-inline'", + ...(clerkOrigin ? [clerkOrigin] : []), + "https://challenges.cloudflare.com", + ]; - if (environment.path.extname(resolvedPath)) { - return resolvedPath; - } + // The renderer connects directly to user-configured environments in addition to + // the build-configured Clerk, relay, and OTLP endpoints. Those environment + // origins are not known when this response policy is created, so restrict + // connections by the network schemes the client supports instead of by host. + const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + + return [ + "default-src 'self'", + `script-src ${scriptSources.join(" ")}`, + `connect-src ${connectSources.join(" ")}`, + `img-src 'self' ${input.scheme}: blob: data: http: https:`, + "style-src 'self' 'unsafe-inline'", + `font-src 'self' ${input.scheme}: data:`, + "worker-src 'self' blob:", + "frame-src 'self' https://challenges.cloudflare.com", + "form-action 'self'", + ].join("; "); +} - const nestedIndex = environment.path.join(resolvedPath, "index.html"); - const nestedIndexExists = yield* fileSystem - .exists(nestedIndex) - .pipe(Effect.orElseSucceed(() => false)); - if (nestedIndexExists) { - return nestedIndex; - } +function withContentSecurityPolicy(response: Response, policy: string): Response { + const headers = new Headers(response.headers); + headers.set("Content-Security-Policy", policy); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} - return environment.path.join(staticRoot, "index.html"); - }, -); +async function proxyRequest( + request: Request, + targetOrigin: URL, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return new Response(null, { status: 404 }); + } -function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { - try { - const url = new URL(requestUrl); - return environment.path.extname(url.pathname).length > 0; - } catch { - return false; + const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); + const init: RequestInit = { + method: request.method, + headers: request.headers, + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + (init as RequestInit & { duplex: "half" }).duplex = "half"; } + const response = await Electron.net.fetch(targetUrl.toString(), init); + return withContentSecurityPolicy(response, contentSecurityPolicy); } -const make = Effect.gen(function* () { - const registeredProtocols = yield* Ref.make>(new Set()); +export const make = Effect.gen(function* () { + const registered = yield* Ref.make(false); - const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( - function* ({ - scheme, - handler, - onFailure, - }: { - readonly scheme: string; - readonly handler: ( - request: Electron.ProtocolRequest, - ) => Effect.Effect; - readonly onFailure?: ( - request: Electron.ProtocolRequest, - cause: Cause.Cause, - ) => Electron.ProtocolResponse; - }): Effect.fn.Return { - yield* Effect.annotateCurrentSpan({ scheme }); - const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( - Effect.map((protocols) => protocols.has(scheme)), - ); - if (alreadyRegistered) { - return; - } + const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( + function* (input: DesktopProtocolRegistrationInput) { + if (yield* Ref.get(registered)) return; - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); + const contentSecurityPolicy = makeDesktopContentSecurityPolicy(input); yield* Effect.acquireRelease( Effect.try({ try: () => { - const registered = Electron.protocol.registerFileProtocol( - scheme, - (request, callback) => { - const response = handler(request).pipe( - Effect.withSpan("desktop.electron.protocol.handleFileRequest"), - Effect.catchCause((cause) => - Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), - ), - ); - - void runPromise(response).then(callback, () => callback({ error: -2 })); - }, + Electron.protocol.handle(input.scheme, (request) => + proxyRequest(request, input.targetOrigin, contentSecurityPolicy), ); - if (!registered) { - throw new ElectronProtocolRegistrationError({ - scheme, - cause: "registerFileProtocol returned false", - }); - } }, - catch: (cause) => - cause instanceof ElectronProtocolRegistrationError - ? cause - : new ElectronProtocolRegistrationError({ scheme, cause }), - }).pipe( - Effect.andThen( - Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), - ), - ), + catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), + }).pipe(Effect.andThen(Ref.set(registered, true))), () => - Effect.sync(() => { - Electron.protocol.unregisterProtocol(scheme); - }).pipe( - Effect.andThen( - Ref.update(registeredProtocols, (protocols) => { - const next = new Set(protocols); - next.delete(scheme); - return next; + Effect.try({ + try: () => Electron.protocol.unhandle(input.scheme), + catch: (cause) => + new ElectronProtocolUnregistrationError({ + scheme: input.scheme, + cause, }), - ), - ), + }).pipe(Effect.andThen(Ref.set(registered, false)), Effect.orDie), ); }, ); - const registerDesktopFileProtocol = Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (environment.isDevelopment) return; - - const staticRoot = yield* resolveDesktopStaticDir; - if (Option.isNone(staticRoot)) { - return yield* new ElectronProtocolStaticBundleMissingError(); - } - - const staticRootResolved = environment.path.resolve(staticRoot.value); - const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; - const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); - - yield* registerFileProtocol({ - scheme: DESKTOP_SCHEME, - handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = environment.path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url, environment); - const exists = yield* fileSystem - .exists(resolvedCandidate) - .pipe(Effect.orElseSucceed(() => false)); - - if (!isInRoot || !exists) { - return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); - } - - return { path: resolvedCandidate } as const; - }), - onFailure: () => ({ path: fallbackIndex }), - }); - }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); - - return ElectronProtocol.of({ - registerFileProtocol, - registerDesktopFileProtocol, - }); + return ElectronProtocol.of({ registerDesktopProtocol }); }); export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index c7b46265887..76162c1647a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,56 +1,69 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( +const electronSafeStorageErrorFields = { + cause: Schema.Defect(), +}; + +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( "ElectronSafeStorageAvailabilityError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to check encryption availability."; } } -export class ElectronSafeStorageEncryptError extends Data.TaggedError( +export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageEncryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to encrypt a string."; } } -export class ElectronSafeStorageDecryptError extends Data.TaggedError( +export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageDecryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to decrypt a string."; } } -export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; -} +export const ElectronSafeStorageError = Schema.Union([ + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageEncryptError, + ElectronSafeStorageDecryptError, +]); +export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; +export const isElectronSafeStorageError = Schema.is(ElectronSafeStorageError); export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, - ElectronSafeStorageShape + { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; + } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -const make = ElectronSafeStorage.of({ +export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0ecce3bf70e..316d3138bfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -20,16 +20,15 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { } } -export interface ElectronShellShape { - readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; -} - -export class ElectronShell extends Context.Service()( - "@t3tools/desktop/electron/ElectronShell", -) {} +export class ElectronShell extends Context.Service< + ElectronShell, + { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronShell") {} -const make = ElectronShell.of({ +export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 0ba7482aace..4b81943eff2 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -8,6 +8,7 @@ const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ themeState: { shouldUseDarkColors: true, themeSource: "system", + setSourceError: null as unknown, }, })); @@ -17,6 +18,9 @@ vi.mock("electron", () => ({ return themeState.shouldUseDarkColors; }, set themeSource(value: string) { + if (themeState.setSourceError !== null) { + throw themeState.setSourceError; + } themeState.themeSource = value; }, on: onMock, @@ -32,6 +36,7 @@ describe("ElectronTheme", () => { removeListenerMock.mockClear(); themeState.shouldUseDarkColors = true; themeState.themeSource = "system"; + themeState.setSourceError = null; }); it.effect("scopes native theme update listeners", () => @@ -49,4 +54,21 @@ describe("ElectronTheme", () => { assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); }).pipe(Effect.provide(ElectronTheme.layer)), ); + + it.effect("preserves the requested source and cause when setting the theme fails", () => + Effect.gen(function* () { + const cause = new Error("theme source failed"); + themeState.setSourceError = cause; + const electronTheme = yield* ElectronTheme.ElectronTheme; + + const error = yield* Effect.flip(electronTheme.setSource("dark")); + + assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); + assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); + assert.strictEqual(error.source, "dark"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "dark"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 1e23d228504..ef47e3d0954 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,27 +1,43 @@ -import type { DesktopTheme } from "@t3tools/contracts"; +import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; -export interface ElectronThemeShape { - readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; - readonly onUpdated: (listener: () => void) => Effect.Effect; +export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass()( + "ElectronThemeSetSourceError", + { + source: DesktopThemeSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to set the Electron theme source to ${this.source}.`; + } } -export class ElectronTheme extends Context.Service()( - "@t3tools/desktop/electron/ElectronTheme", -) {} +export const isElectronThemeSetSourceError = Schema.is(ElectronThemeSetSourceError); -const make = ElectronTheme.of({ +export class ElectronTheme extends Context.Service< + ElectronTheme, + { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTheme") {} + +export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => - Effect.suspend(() => { - Electron.nativeTheme.themeSource = theme; - return Effect.void; + Effect.try({ + try: () => { + Electron.nativeTheme.themeSource = theme; + }, + catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), }), onUpdated: (listener) => Effect.acquireRelease( diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d2d3edd3696..8fcc34f41c2 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -65,15 +64,65 @@ describe("ElectronUpdater", () => { const cause = new Error("network unavailable"); autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "beta"; - const exit = yield* Effect.exit(updater.checkForUpdates); + const error = yield* updater.checkForUpdates.pipe(Effect.flip); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.cause, cause); - } + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "beta"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves the execution-time channel on download failures", () => + Effect.gen(function* () { + const cause = new Error("download unavailable"); + autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "nightly"; + + const error = yield* updater.downloadUpdate.pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to download the update on channel nightly.", + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves quit-and-install flags and the execution-time channel", () => + Effect.gen(function* () { + const cause = new Error("quit and install failed"); + autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { + throw cause; + }); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "alpha"; + + const error = yield* updater + .quitAndInstall({ isSilent: true, isForceRunAfter: false }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "alpha"); + assert.equal(error.isSilent, true); + assert.equal(error.isForceRunAfter, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", + ); + assert.notInclude(error.message, cause.message); + assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 7f3edf02aa8..435fbd00228 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; @@ -10,67 +10,77 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to check for updates."; + { + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to check for updates on channel ${this.channel ?? "default"}.`; } } -export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to download the update."; + { + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to download the update on channel ${this.channel ?? "default"}.`; } } -export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron updater failed to quit and install the update."; + { + channel: Schema.NullOr(Schema.String), + isSilent: Schema.Boolean, + isForceRunAfter: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Electron updater failed to quit and install the update on channel ${this.channel ?? "default"} (silent: ${this.isSilent}, force run after: ${this.isForceRunAfter}).`; } } -export type ElectronUpdaterError = - | ElectronUpdaterCheckForUpdatesError - | ElectronUpdaterDownloadUpdateError - | ElectronUpdaterQuitAndInstallError; - -export interface ElectronUpdaterShape { - readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; - readonly setAutoDownload: (value: boolean) => Effect.Effect; - readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; - readonly setChannel: (channel: string) => Effect.Effect; - readonly setAllowPrerelease: (value: boolean) => Effect.Effect; - readonly allowDowngrade: Effect.Effect; - readonly setAllowDowngrade: (value: boolean) => Effect.Effect; - readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; - readonly quitAndInstall: (options: { - readonly isSilent: boolean; - readonly isForceRunAfter: boolean; - }) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} +export const ElectronUpdaterError = Schema.Union([ + ElectronUpdaterCheckForUpdatesError, + ElectronUpdaterDownloadUpdateError, + ElectronUpdaterQuitAndInstallError, +]); +export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; +export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); -export class ElectronUpdater extends Context.Service()( - "@t3tools/desktop/electron/ElectronUpdater", -) {} +export class ElectronUpdater extends Context.Service< + ElectronUpdater, + { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronUpdater") {} -export const layer = Layer.succeed(ElectronUpdater, { +export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { autoUpdater.setFeedURL(options); @@ -107,18 +117,33 @@ export const layer = Layer.succeed(ElectronUpdater, { autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - checkForUpdates: Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), - }).pipe(Effect.asVoid), - downloadUpdate: Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), - }).pipe(Effect.asVoid), + checkForUpdates: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), + }).pipe(Effect.asVoid); + }), + downloadUpdate: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), + }).pipe(Effect.asVoid); + }), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + }); }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { @@ -136,4 +161,6 @@ export const layer = Layer.succeed(ElectronUpdater, { }), ).pipe(Effect.asVoid); }, -} satisfies ElectronUpdaterShape); +}); + +export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35c1fbc5faa..0bf98a9610e 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,43 +1,45 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ - readonly cause: unknown; -}> { - override get message() { +export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( + "ElectronWindowCreateError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { return "Failed to create Electron BrowserWindow."; } } -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 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()); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 00000000000..fc311877f82 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import * as DesktopIpc from "./DesktopIpc.ts"; + +const invokeMethod: DesktopIpc.DesktopIpcMethod = { + channel: "desktop.test.invoke", + handler: () => Effect.void, +}; + +const syncMethod: DesktopIpc.DesktopSyncIpcMethod = { + channel: "desktop.test.sync", + handler: () => Effect.void, +}; + +function makeIpcMain( + overrides: Partial = {}, +): DesktopIpc.DesktopIpcMain { + return { + removeHandler: vi.fn(), + handle: vi.fn(), + removeAllListeners: vi.fn(), + on: vi.fn(), + ...overrides, + }; +} + +describe("DesktopIpc", () => { + it.effect("preserves invoke registration context and cause", () => + Effect.gen(function* () { + const cause = new Error("invoke registration failed"); + const ipcMain = makeIpcMain({ + handle: () => { + throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); + + assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "invoke"); + assert.strictEqual(error.channel, invokeMethod.channel); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "invoke"); + assert.include(error.message, invokeMethod.channel); + assert.notInclude(error.message, cause.message); + }), + ); + + it.effect("preserves sync unregistration context and cause in the finalizer defect", () => + Effect.gen(function* () { + const cause = new Error("sync unregistration failed"); + let removeCount = 0; + const ipcMain = makeIpcMain({ + removeAllListeners: () => { + removeCount += 1; + if (removeCount === 2) throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod))); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "sync"); + assert.strictEqual(error.channel, syncMethod.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }), + ); +}); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 6d954a97aec..e948571cc62 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -23,6 +24,39 @@ export interface DesktopIpcMain { on(channel: string, listener: DesktopIpcSyncListener): void; } +export class DesktopIpcRegistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcRegistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export class DesktopIpcUnregistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcUnregistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export const DesktopIpcError = Schema.Union([ + DesktopIpcRegistrationError, + DesktopIpcUnregistrationError, +]); +export type DesktopIpcError = typeof DesktopIpcError.Type; +export const isDesktopIpcError = Schema.is(DesktopIpcError); + export interface DesktopIpcMethod { readonly channel: string; readonly handler: (raw: unknown) => Effect.Effect; @@ -33,20 +67,19 @@ export interface DesktopSyncIpcMethod { readonly handler: () => Effect.Effect; } -export interface DesktopIpcShape { - readonly handle: ( - input: DesktopIpcMethod, - ) => Effect.Effect; - readonly handleSync: ( - input: DesktopSyncIpcMethod, - ) => Effect.Effect; -} - -export class DesktopIpc extends Context.Service()( - "@t3tools/desktop/ipc/DesktopIpc", -) {} +export class DesktopIpc extends Context.Service< + DesktopIpc, + { + readonly handle: ( + input: DesktopIpcMethod, + ) => Effect.Effect; + readonly handleSync: ( + input: DesktopSyncIpcMethod, + ) => Effect.Effect; + } +>()("@t3tools/desktop/ipc/DesktopIpc") {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => +export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => const runPromise = Effect.runPromiseWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeHandler(channel); - ipcMain.handle(channel, (_event, raw) => - runPromise( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(raw); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), - ), - ); + Effect.try({ + try: () => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeHandler(channel)), + () => + Effect.try({ + try: () => ipcMain.removeHandler(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "invoke", channel, cause }), + }).pipe(Effect.orDie), ); }), @@ -81,22 +123,36 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => const runSync = Effect.runSyncWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeAllListeners(channel); - ipcMain.on(channel, (event) => { - event.returnValue = runSync( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), - ); - }); + Effect.try({ + try: () => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe( + Effect.annotateLogs({ channel }), + Effect.withSpan("desktop.ipc.invokeSync"), + ), + ); + }); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + () => + Effect.try({ + try: () => ipcMain.removeAllListeners(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "sync", channel, cause }), + }).pipe(Effect.orDie), ); }), }); +export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); + /** * Convenience helpers for creating IPC methods */ diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a6c8428efa9..180e44e52d9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,21 +1,12 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; -import { - clearCloudAuthToken, - createCloudAuthRequest, - fetchCloudAuth, - getCloudAuthToken, - setCloudAuthToken, -} from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { - getSavedEnvironmentRegistry, - getSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - setSavedEnvironmentRegistry, - setSavedEnvironmentSecret, -} from "./methods/savedEnvironments.ts"; + clearConnectionCatalog, + getConnectionCatalog, + setConnectionCatalog, +} from "./methods/connectionCatalog.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -42,6 +33,7 @@ import { import { confirm, getAppBranding, + getLocalEnvironmentBearerToken, getLocalEnvironmentBootstrap, openExternal, pickFolder, @@ -56,14 +48,13 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); + yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); - yield* ipc.handle(getSavedEnvironmentRegistry); - yield* ipc.handle(setSavedEnvironmentRegistry); - yield* ipc.handle(getSavedEnvironmentSecret); - yield* ipc.handle(setSavedEnvironmentSecret); - yield* ipc.handle(removeSavedEnvironmentSecret); + yield* ipc.handle(getConnectionCatalog); + yield* ipc.handle(setConnectionCatalog); + yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(ensureSshEnvironment); @@ -84,11 +75,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); - yield* ipc.handle(createCloudAuthRequest); - yield* ipc.handle(getCloudAuthToken); - yield* ipc.handle(setCloudAuthToken); - yield* ipc.handle(clearCloudAuthToken); - yield* ipc.handle(fetchCloudAuth); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c5dabe0930f..cc2a92ca8fd 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,12 +3,6 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request"; -export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; -export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; -export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; -export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; -export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -18,13 +12,13 @@ export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = + "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; +export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; +export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index 52b173266cd..dd0625759e9 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -5,9 +5,9 @@ import * as Schema from "effect/Schema"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; -export const getClientSettings = makeIpcMethod({ +export const getClientSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, payload: Schema.Void, result: Schema.NullOr(ClientSettingsSchema), @@ -17,7 +17,7 @@ export const getClientSettings = makeIpcMethod({ }), }); -export const setClientSettings = makeIpcMethod({ +export const setClientSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, payload: ClientSettingsSchema, result: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/cloudAuth.test.ts b/apps/desktop/src/ipc/methods/cloudAuth.test.ts deleted file mode 100644 index c5f1e2b2c90..00000000000 --- a/apps/desktop/src/ipc/methods/cloudAuth.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { afterEach } from "vite-plus/test"; - -import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts"; - -const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY; -const originalFetch = globalThis.fetch; - -const clerkPublishableKey = (hostname: string): string => - `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`; - -type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; - -const recordedFetch = (...responses: ReadonlyArray) => { - const calls: Array = []; - let responseIndex = 0; - const fetchFn = ((input, init) => { - calls.push([input, init ?? {}]); - const response = responses[responseIndex++]; - if (!response) { - return Promise.reject(new Error("Unexpected fetch call")); - } - return Promise.resolve(response); - }) satisfies typeof fetch; - - return { fetchFn, calls }; -}; - -describe("Desktop cloud auth IPC", () => { - afterEach(() => { - globalThis.fetch = originalFetch; - if (originalClerkPublishableKey === undefined) { - delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY; - } else { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = originalClerkPublishableKey; - } - }); - - it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { - const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; - const fetch = recordedFetch(Response.json({ response: { object: "sign_in_attempt" } })); - globalThis.fetch = fetch.fetchFn; - - return Effect.gen(function* () { - yield* fetchCloudAuth.handler({ - url: "https://example.clerk.accounts.dev/v1/client/sign_ins", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - "x-mobile": "1", - }, - body, - }); - - const forwardedRequest = fetch.calls[0]; - assert(forwardedRequest !== undefined); - const [url, init] = forwardedRequest; - assert.equal(String(url), "https://example.clerk.accounts.dev/v1/client/sign_ins"); - assert.equal(init.method, "POST"); - assert.equal( - new Headers(init.headers).get("content-type"), - "application/x-www-form-urlencoded;charset=UTF-8", - ); - assert.equal(new TextDecoder().decode(init.body as Uint8Array), body); - }); - }); - - it.effect( - "allows the custom Clerk Frontend API host encoded by the configured publishable key", - () => { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); - const fetch = recordedFetch(Response.json({ response: { object: "client" } })); - globalThis.fetch = fetch.fetchFn; - - return Effect.gen(function* () { - yield* fetchCloudAuth.handler({ - url: "https://clerk.t3.codes/v1/client", - method: "GET", - headers: {}, - }); - - const forwardedRequest = fetch.calls[0]; - assert(forwardedRequest !== undefined); - assert.equal(String(forwardedRequest[0]), "https://clerk.t3.codes/v1/client"); - }); - }, - ); - - it("rejects arbitrary HTTPS hosts that are not configured Clerk Frontend API hosts", () => { - process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); - assert.throws( - () => validateClerkFrontendApiUrl("https://attacker.example/v1/client"), - /restricted to Clerk Frontend API HTTPS hosts/u, - ); - }); -}); diff --git a/apps/desktop/src/ipc/methods/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts deleted file mode 100644 index a5a7aacff79..00000000000 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - DesktopCloudAuthFetchInputSchema, - DesktopCloudAuthFetchResultSchema, -} from "@t3tools/contracts"; -import { - clerkFrontendApiHostnameFromPublishableKey, - isAllowedClerkFrontendApiHostname, -} from "@t3tools/shared/relayAuth"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import { identity } from "effect/Function"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; - -import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; -import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; - -export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message() { - return this.reason; - } -} - -function configuredClerkFrontendApiHostname(): string | null { - const publishableKey = - process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || - (typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? "" - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__.trim()); - if (!publishableKey) return null; - - return clerkFrontendApiHostnameFromPublishableKey(publishableKey); -} - -const allowedClerkFrontendApiHosts = (hostname: string): boolean => - isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname()); - -export function validateClerkFrontendApiUrl(rawUrl: string): URL { - const url = new URL(rawUrl); - if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) { - throw new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch is restricted to Clerk Frontend API HTTPS hosts.", - }); - } - return url; -} - -function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInputSchema.Type) { - return Effect.gen(function* () { - const method = (input.method ?? "GET") as "GET" | "POST"; - const headers = new Headers(input.headers); - const response = yield* HttpClientRequest.make(method)(url).pipe( - HttpClientRequest.setHeaders(headers), - input.body === undefined - ? identity - : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), - HttpClient.execute, - Effect.mapError( - (cause) => - new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch failed to execute.", - cause, - }), - ), - ); - - const body = yield* response.text.pipe( - Effect.mapError( - (cause) => - new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch response could not be read.", - cause, - }), - ), - ); - - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: "", - headers: response.headers, - body, - }; - }); -} - -const electronNetFetchLayer = Layer.unwrap( - Effect.gen(function* () { - const electronFetch = yield* Effect.promise(async () => { - const electron = (await import("electron")) as { - readonly net?: { readonly fetch?: typeof globalThis.fetch }; - }; - return typeof electron.net?.fetch === "function" - ? electron.net.fetch.bind(electron.net) - : null; - }).pipe(Effect.catchCause(() => Effect.succeed(null))); - - if (!electronFetch) { - yield* Effect.logWarning( - "electron.net.fetch is not available, falling back to global fetch. This may cause unexpected errors.", - ); - } - - return FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch, electronFetch ?? globalThis.fetch)), - ); - }), -); - -export const createCloudAuthRequest = makeIpcMethod({ - channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL, - payload: Schema.Void, - result: Schema.String, - handler: Effect.fn("desktop.ipc.cloudAuth.createRequest")(function* () { - const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; - return yield* cloudAuth.createRequest; - }), -}); - -export const getCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.Void, - result: Schema.NullOr(Schema.String), - handler: Effect.fn("desktop.ipc.cloudAuth.getToken")(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - return Option.getOrNull(yield* tokenStore.get); - }), -}); - -export const setCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.String, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.cloudAuth.setToken")(function* (token) { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - return yield* tokenStore.set(token); - }), -}); - -export const clearCloudAuthToken = makeIpcMethod({ - channel: IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.cloudAuth.clearToken")(function* () { - const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; - yield* tokenStore.clear; - }), -}); - -export const fetchCloudAuth = makeIpcMethod({ - channel: IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, - payload: DesktopCloudAuthFetchInputSchema, - result: DesktopCloudAuthFetchResultSchema, - handler: Effect.fn("desktop.ipc.cloudAuth.fetch")(function* (input) { - const url = yield* Effect.try({ - try: () => validateClerkFrontendApiUrl(input.url), - catch: (cause) => - cause instanceof DesktopCloudAuthFetchError - ? cause - : new DesktopCloudAuthFetchError({ - reason: "Desktop cloud auth fetch received an invalid URL.", - cause, - }), - }); - - return yield* executeCloudAuthFetch(url, input).pipe(Effect.provide(electronNetFetchLayer)); - }), -}); diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts new file mode 100644 index 00000000000..4e51496a637 --- /dev/null +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const getConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.connectionCatalog.get")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return Option.getOrNull(yield* store.get); + }), +}); + +export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.connectionCatalog.set")(function* (catalog) { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return yield* store.set(catalog); + }), +}); + +export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + yield* store.clear; + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8adae374ad0..1994c270024 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -22,12 +22,12 @@ import { import { BrowserWindow } from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; const broadcast = (channel: string, ...args: ReadonlyArray): void => { for (const window of BrowserWindow.getAllWindows()) { @@ -52,7 +52,7 @@ export const installPreviewEventForwarding = Effect.fn( }); }); -export const createTab = makeIpcMethod({ +export const createTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -62,7 +62,7 @@ export const createTab = makeIpcMethod({ }), }); -export const closeTab = makeIpcMethod({ +export const closeTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -72,7 +72,7 @@ export const closeTab = makeIpcMethod({ }), }); -export const registerWebview = makeIpcMethod({ +export const registerWebview = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, payload: DesktopPreviewRegisterWebviewInputSchema, result: Schema.Void, @@ -82,7 +82,7 @@ export const registerWebview = makeIpcMethod({ }), }); -export const navigate = makeIpcMethod({ +export const navigate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, payload: DesktopPreviewNavigateInputSchema, result: Schema.Void, @@ -96,11 +96,11 @@ const tabMethod = ( channel: string, name: string, invoke: ( - manager: PreviewManager.PreviewManagerShape, + manager: PreviewManager.PreviewManager["Service"], tabId: string, ) => Effect.Effect, ) => - makeIpcMethod({ + DesktopIpc.makeIpcMethod({ channel, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -166,7 +166,7 @@ export const stopRecording = tabMethod( (manager, tabId) => manager.stopRecording(tabId), ); -export const clearCookies = makeIpcMethod({ +export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, payload: Schema.Void, result: Schema.Void, @@ -176,7 +176,7 @@ export const clearCookies = makeIpcMethod({ }), }); -export const clearCache = makeIpcMethod({ +export const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, payload: Schema.Void, result: Schema.Void, @@ -186,7 +186,7 @@ export const clearCache = makeIpcMethod({ }), }); -export const getPreviewConfig = makeIpcMethod({ +export const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, @@ -196,12 +196,12 @@ export const getPreviewConfig = makeIpcMethod({ return { partition: yield* manager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, - preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, + preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; }), }); -export const setAnnotationTheme = makeIpcMethod({ +export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, result: Schema.Void, @@ -211,7 +211,7 @@ export const setAnnotationTheme = makeIpcMethod({ }), }); -export const pickElement = makeIpcMethod({ +export const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.NullOr(PreviewAnnotationPayloadSchema), @@ -221,7 +221,7 @@ export const pickElement = makeIpcMethod({ }), }); -export const captureScreenshot = makeIpcMethod({ +export const captureScreenshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: DesktopPreviewScreenshotArtifactSchema, @@ -231,7 +231,7 @@ export const captureScreenshot = makeIpcMethod({ }), }); -export const revealArtifact = makeIpcMethod({ +export const revealArtifact = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -241,7 +241,7 @@ export const revealArtifact = makeIpcMethod({ }), }); -export const copyArtifactToClipboard = makeIpcMethod({ +export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -251,7 +251,7 @@ export const copyArtifactToClipboard = makeIpcMethod({ }), }); -export const automationStatus = makeIpcMethod({ +export const automationStatus = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, payload: DesktopPreviewTabInputSchema, result: PreviewAutomationStatus, @@ -261,7 +261,7 @@ export const automationStatus = makeIpcMethod({ }), }); -export const automationSnapshot = makeIpcMethod({ +export const automationSnapshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: PreviewAutomationSnapshot, @@ -271,7 +271,7 @@ export const automationSnapshot = makeIpcMethod({ }), }); -export const automationClick = makeIpcMethod({ +export const automationClick = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, payload: DesktopPreviewAutomationClickInputSchema, result: Schema.Void, @@ -281,7 +281,7 @@ export const automationClick = makeIpcMethod({ }), }); -export const automationType = makeIpcMethod({ +export const automationType = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, payload: DesktopPreviewAutomationTypeInputSchema, result: Schema.Void, @@ -291,7 +291,7 @@ export const automationType = makeIpcMethod({ }), }); -export const automationPress = makeIpcMethod({ +export const automationPress = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, payload: DesktopPreviewAutomationPressInputSchema, result: Schema.Void, @@ -301,7 +301,7 @@ export const automationPress = makeIpcMethod({ }), }); -export const automationScroll = makeIpcMethod({ +export const automationScroll = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, payload: DesktopPreviewAutomationScrollInputSchema, result: Schema.Void, @@ -311,7 +311,7 @@ export const automationScroll = makeIpcMethod({ }), }); -export const automationEvaluate = makeIpcMethod({ +export const automationEvaluate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, payload: DesktopPreviewAutomationEvaluateInputSchema, result: Schema.Unknown, @@ -321,7 +321,7 @@ export const automationEvaluate = makeIpcMethod({ }), }); -export const automationWaitFor = makeIpcMethod({ +export const automationWaitFor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, payload: DesktopPreviewAutomationWaitForInputSchema, result: Schema.Void, @@ -331,7 +331,7 @@ export const automationWaitFor = makeIpcMethod({ }), }); -export const saveRecording = makeIpcMethod({ +export const saveRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, payload: DesktopPreviewRecordingSaveInputSchema, result: DesktopPreviewRecordingArtifactSchema, diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts deleted file mode 100644 index bc5e4a9aeb2..00000000000 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); -const NonBlankString = Schema.String.check( - Schema.makeFilter((value) => - value.trim().length > 0 ? undefined : "Expected a non-empty string", - ), -); - -const SetSavedEnvironmentSecretInput = Schema.Struct({ - environmentId: EnvironmentId, - secret: NonBlankString, -}); - -export const getSavedEnvironmentRegistry = makeIpcMethod({ - channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - payload: Schema.Void, - result: SavedEnvironmentRegistryPayload, - handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.getRegistry; - }), -}); - -export const setSavedEnvironmentRegistry = makeIpcMethod({ - channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - payload: SavedEnvironmentRegistryPayload, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry(records); - }), -}); - -export const getSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: EnvironmentId, - result: Schema.NullOr(Schema.String), - handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); - }), -}); - -export const setSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: SetSavedEnvironmentSecretInput, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ - environmentId, - secret, - }) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.setSecret({ - environmentId, - secret, - }); - }), -}); - -export const removeSavedEnvironmentSecret = makeIpcMethod({ - channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - payload: EnvironmentId, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.removeSecret(environmentId); - }), -}); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index cd0f215e193..9a9ce768973 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -9,14 +9,14 @@ import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ enabled: Schema.Boolean, port: Schema.optionalKey(Schema.Number), }); -export const getServerExposureState = makeIpcMethod({ +export const getServerExposureState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, payload: Schema.Void, result: DesktopServerExposureStateSchema, @@ -26,7 +26,7 @@ export const getServerExposureState = makeIpcMethod({ }), }); -export const setServerExposureMode = makeIpcMethod({ +export const setServerExposureMode = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, payload: DesktopServerExposureModeSchema, result: DesktopServerExposureStateSchema, @@ -41,7 +41,7 @@ export const setServerExposureMode = makeIpcMethod({ }), }); -export const setTailscaleServeEnabled = makeIpcMethod({ +export const setTailscaleServeEnabled = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, payload: SetTailscaleServeEnabledInput, result: DesktopServerExposureStateSchema, @@ -58,7 +58,7 @@ export const setTailscaleServeEnabled = makeIpcMethod({ }), }); -export const getAdvertisedEndpoints = makeIpcMethod({ +export const getAdvertisedEndpoints = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, payload: Schema.Void, result: Schema.Array(AdvertisedEndpoint), diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 6eeaa3202d9..9c9af2a4e2b 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,11 +1,11 @@ import { bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { EnvironmentAuthInvalidError, DesktopDiscoveredSshHostSchema, @@ -33,7 +33,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; @@ -107,7 +107,7 @@ const withLoopbackSshApi = ), ); -export const discoverSshHosts = makeIpcMethod({ +export const discoverSshHosts = DesktopIpc.makeIpcMethod({ channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, payload: Schema.Void, result: Schema.Array(DesktopDiscoveredSshHostSchema), @@ -117,7 +117,7 @@ export const discoverSshHosts = makeIpcMethod({ }), }); -export const ensureSshEnvironment = makeIpcMethod({ +export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, result: DesktopSshEnvironmentEnsureResultSchema, @@ -139,7 +139,7 @@ export const ensureSshEnvironment = makeIpcMethod({ }), }); -export const disconnectSshEnvironment = makeIpcMethod({ +export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentTargetSchema, result: Schema.Void, @@ -149,7 +149,7 @@ export const disconnectSshEnvironment = makeIpcMethod({ }), }); -export const fetchSshEnvironmentDescriptor = makeIpcMethod({ +export const fetchSshEnvironmentDescriptor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, payload: DesktopSshHttpBaseUrlInputSchema, result: ExecutionEnvironmentDescriptor, @@ -160,7 +160,7 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ }), }); -export const bootstrapSshBearerSession = makeIpcMethod({ +export const bootstrapSshBearerSession = DesktopIpc.makeIpcMethod({ channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, payload: DesktopSshBearerBootstrapInputSchema, result: AuthAccessTokenResult, @@ -177,7 +177,7 @@ export const bootstrapSshBearerSession = makeIpcMethod({ }), }); -export const fetchSshSessionState = makeIpcMethod({ +export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthSessionState, @@ -194,7 +194,7 @@ export const fetchSshSessionState = makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = makeIpcMethod({ +export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthWebSocketTicketResult, @@ -211,7 +211,7 @@ export const issueSshWebSocketTicket = makeIpcMethod({ }), }); -export const resolveSshPasswordPrompt = makeIpcMethod({ +export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, result: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 45ea8502121..b2212609030 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -9,9 +9,9 @@ import * as Schema from "effect/Schema"; import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; -export const getUpdateState = makeIpcMethod({ +export const getUpdateState = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, payload: Schema.Void, result: DesktopUpdateStateSchema, @@ -21,7 +21,7 @@ export const getUpdateState = makeIpcMethod({ }), }); -export const setUpdateChannel = makeIpcMethod({ +export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, payload: DesktopUpdateChannelSchema, result: DesktopUpdateStateSchema, @@ -31,7 +31,7 @@ export const setUpdateChannel = makeIpcMethod({ }), }); -export const downloadUpdate = makeIpcMethod({ +export const downloadUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, @@ -41,7 +41,7 @@ export const downloadUpdate = makeIpcMethod({ }), }); -export const installUpdate = makeIpcMethod({ +export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, @@ -51,7 +51,7 @@ export const installUpdate = makeIpcMethod({ }), }); -export const checkForUpdate = makeIpcMethod({ +export const checkForUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_CHECK_CHANNEL, payload: Schema.Void, result: DesktopUpdateCheckResultSchema, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1cb4d7265a1..3cb705d0361 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; @@ -17,7 +18,7 @@ import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -35,7 +36,7 @@ function toWebSocketBaseUrl(httpBaseUrl: URL): string { return url.href; } -export const getAppBranding = makeSyncIpcMethod({ +export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { @@ -44,7 +45,7 @@ export const getAppBranding = makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ +export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { @@ -64,7 +65,17 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ }), }); -export const pickFolder = makeIpcMethod({ +export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, + payload: Schema.Void, + result: Schema.String, + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBearerToken")(function* () { + const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; + return yield* localAuth.getBearerToken; + }), +}); + +export const pickFolder = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), result: Schema.NullOr(Schema.String), @@ -80,7 +91,7 @@ export const pickFolder = makeIpcMethod({ }), }); -export const confirm = makeIpcMethod({ +export const confirm = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONFIRM_CHANNEL, payload: Schema.String, result: Schema.Boolean, @@ -93,7 +104,7 @@ export const confirm = makeIpcMethod({ }), }); -export const setTheme = makeIpcMethod({ +export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, result: Schema.Void, @@ -103,7 +114,7 @@ export const setTheme = makeIpcMethod({ }), }); -export const showContextMenu = makeIpcMethod({ +export const showContextMenu = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, result: Schema.NullOr(Schema.String), @@ -124,7 +135,7 @@ export const showContextMenu = makeIpcMethod({ }), }); -export const openExternal = makeIpcMethod({ +export const openExternal = DesktopIpc.makeIpcMethod({ channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, payload: Schema.String, result: Schema.Boolean, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ed0b9b5cf0..b88eb18e57f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,27 +14,29 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; -import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; -import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; -import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; -import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; +import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; +import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; @@ -45,7 +47,7 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; -import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; +import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -67,8 +69,8 @@ const desktopEnvironmentLayer = Layer.unwrap( ); const resolveDesktopSshCliRunner = ( - environment: DesktopEnvironment.DesktopEnvironmentShape, - settings: DesktopSettingsValue, + environment: DesktopEnvironment.DesktopEnvironment["Service"], + settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -104,21 +106,20 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronProtocol.layer, - DesktopSecretStorage.layer, + ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), + DesktopIpc.layer(Electron.ipcMain), ); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, - DesktopLifecycle.layerShutdown, + DesktopShutdown.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopSavedEnvironments.layer, - DesktopCloudAuthTokenStore.layer, + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); @@ -128,12 +129,12 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), ); const desktopPreviewLayer = PreviewManager.layer.pipe( - Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); @@ -148,17 +149,30 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(desktopWindowLayer), ); +const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provideMerge(desktopBackendLayer), +); + const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, - DesktopCloudAuth.layer, DesktopShellEnvironment.layer, desktopSshLayer, -).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); +).pipe( + Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(desktopLocalEnvironmentAuthLayer), +); + +const desktopClerkLayer = DesktopClerk.layer.pipe( + Layer.provideMerge(desktopEnvironmentLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ElectronApp.layer), +); -const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( - Layer.flatMap(() => +const desktopRuntimeLayer = desktopClerkLayer.pipe( + Layer.flatMap((clerkContext) => desktopApplicationLayer.pipe( + Layer.provideMerge(Layer.succeedContext(clerkContext)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ce12f19bf72..6f126f41334 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,10 +4,13 @@ import type { DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; +import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +exposeClerkBridge({ passkeys: true }); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -39,19 +42,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getLocalEnvironmentBearerToken: () => + ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), - getSavedEnvironmentRegistry: () => - ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), - setSavedEnvironmentRegistry: (records) => - ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), - getSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), - removeSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), + setConnectionCatalog: (catalog) => + ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -101,23 +100,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), - createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL), - getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL), - setCloudAuthToken: (token: string) => - ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), - clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), - fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), - onCloudAuthCallback: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { - if (typeof rawUrl !== "string") return; - listener(rawUrl); - }; - - ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); - return () => { - ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); - }; - }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index ead28c12f9b..7155b975f78 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -2,36 +2,38 @@ import type { Session } from "electron"; import { session } from "electron"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { +export class BrowserSessionError extends Schema.TaggedErrorClass()( + "BrowserSessionError", + { + operation: Schema.Literals(["getPartition", "getSession", "clearCookies", "clearCache"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { return `Desktop preview browser session operation failed: ${this.operation}`; } } -export interface BrowserSessionShape { - readonly getPartition: (scope?: string) => Effect.Effect; - readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; -} - -export class BrowserSession extends Context.Service()( - "@t3tools/desktop/preview/BrowserSession", -) {} +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") {} -const make = Effect.gen(function* BrowserSessionMake() { +export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const sessionsRef = yield* SynchronizedRef.make>(new Map()); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d7252d3f8d9..81b98f4f4e8 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -59,7 +59,7 @@ const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", - } as DesktopEnvironment.DesktopEnvironmentShape), + } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const fileSystemLayer = FileSystem.layerNoop({ @@ -82,7 +82,7 @@ const layer = PreviewManager.layer.pipe( const withManager = ( use: ( - manager: PreviewManager.PreviewManagerShape, + manager: PreviewManager.PreviewManager["Service"], ) => Effect.Effect, ) => Effect.gen(function* () { @@ -128,6 +128,58 @@ describe("PreviewManager", () => { ), ); + 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* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2c4096e8cfb..6d25fc9b2c0 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -37,7 +37,6 @@ import { import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -432,15 +431,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tabs = yield* SynchronizedRef.get(tabsRef); const tab = tabs.get(tabId); - if (!tab) return yield* fail("requireWebContents", new PreviewTabNotFoundError(tabId)); + if (!tab) { + return yield* fail("requireWebContents", new PreviewTabNotFoundError({ tabId })); + } if (tab.webContentsId == null) { - return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError(tabId)); + return yield* fail("requireWebContents", new PreviewWebviewNotInitializedError({ tabId })); } const wc = webContents.fromId(tab.webContentsId); if (!wc) { return yield* fail( "requireWebContents", - new PreviewWebContentsNotFoundError(tabId, tab.webContentsId), + new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId }), ); } return wc; @@ -845,7 +846,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); } else { const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); - const underlying = error instanceof PreviewManagerError ? error.cause : error; + const underlying = isPreviewManagerError(error) ? error.cause : error; const interrupted = underlying instanceof Error && underlying.name === "PreviewAutomationControlInterruptedError"; @@ -1161,7 +1162,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { - return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); + return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); @@ -1172,7 +1173,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* fail( "registerWebview", - new PreviewWebContentsNotFoundError(tabId, webContentsId), + new PreviewWebContentsNotFoundError({ tabId, webContentsId }), ); } const attached = yield* Ref.get(attachedRef); @@ -1195,26 +1196,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* attachListeners(tabId, wc); runFork(ensureControlSession(wc).pipe(Effect.ignore)); - if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( - Effect.ignore, - ); - } - yield* update(tabId, { - webContentsId, - navStatus: computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor: tab.zoomFactor, + const registeredAt = yield* currentIso; + const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if (!current) { + return [ + Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), + tabs, + ] as const; + } + const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; + const next: PreviewTabState = { + ...current, + webContentsId, + navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + updatedAt: registeredAt, + }; + return [ + Option.some({ + state: next, + pendingUrl, + }), + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; }); + if (Option.isNone(registration)) { + return yield* fail("registerWebview", 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("registerWebview.restoreZoom", () => + wc.setZoomFactor(registered.zoomFactor), + ).pipe(Effect.ignore); + } yield* attempt("registerWebview.sendTheme", () => 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("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe( + Effect.ignore, + ), + ); + } }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { - const wc = yield* requireWebContents(tabId); const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl)); + const updatedAt = yield* currentIso; + const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + const next: PreviewTabState = { + tabId, + webContentsId: current?.webContentsId ?? null, + navStatus: { + kind: "Loading", + url, + title: current?.navStatus.kind === "Idle" || !current ? "" : current.navStatus.title, + }, + canGoBack: current?.canGoBack ?? false, + canGoForward: current?.canGoForward ?? false, + zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + controller: current?.controller ?? "none", + updatedAt, + }; + return [ + next, + replaceMap(tabs, (copy) => { + copy.set(tabId, next); + }), + ] as const; + }); + yield* emit(tabId, pending); + if (pending.webContentsId == null) return; + const wc = webContents.fromId(pending.webContentsId); + if (!wc) { + const detached = { ...pending, webContentsId: null }; + yield* SynchronizedRef.update(tabsRef, (tabs) => + tabs.get(tabId)?.webContentsId !== pending.webContentsId + ? tabs + : replaceMap(tabs, (copy) => { + copy.set(tabId, detached); + }), + ); + yield* emit(tabId, detached); + return; + } if (wc.getURL() === url) { yield* attempt("navigate.reload", () => wc.reload()); return; @@ -2123,129 +2201,131 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); -export class PreviewTabNotFoundError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab not found: ${tabId}`); - this.name = "PreviewTabNotFoundError"; - this.tabId = tabId; +export class PreviewTabNotFoundError extends Schema.TaggedErrorClass()( + "PreviewTabNotFoundError", + { tabId: Schema.String }, +) { + override get message(): string { + return `Preview tab not found: ${this.tabId}`; } } -export class PreviewWebContentsNotFoundError extends Error { - readonly tabId: string; - readonly webContentsId: number; - constructor(tabId: string, webContentsId: number) { - super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); - this.name = "PreviewWebContentsNotFoundError"; - this.tabId = tabId; - this.webContentsId = webContentsId; +export class PreviewWebContentsNotFoundError extends Schema.TaggedErrorClass()( + "PreviewWebContentsNotFoundError", + { tabId: Schema.String, webContentsId: Schema.Number }, +) { + override get message(): string { + return `WebContents ${this.webContentsId} not found for preview tab ${this.tabId}`; } } -export class PreviewWebviewNotInitializedError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab "${tabId}" has no webview registered`); - this.name = "PreviewWebviewNotInitializedError"; - this.tabId = tabId; +export class PreviewWebviewNotInitializedError extends Schema.TaggedErrorClass()( + "PreviewWebviewNotInitializedError", + { tabId: Schema.String }, +) { + override get message(): string { + return `Preview tab "${this.tabId}" has no webview registered`; } } -export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ - readonly operation: string; - readonly cause: unknown; -}> { - override get message() { +export class PreviewManagerError extends Schema.TaggedErrorClass()( + "PreviewManagerError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { return `Desktop preview operation failed: ${this.operation}`; } } -export interface PreviewManagerShape { - readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; - readonly getBrowserSession: (scope?: string) => Effect.Effect; - readonly isBrowserPartition: (partition: string) => boolean; - readonly createTab: (tabId: string) => Effect.Effect; - readonly closeTab: (tabId: string) => Effect.Effect; - readonly registerWebview: ( - tabId: string, - webContentsId: number, - ) => Effect.Effect; - readonly navigate: (tabId: string, url: string) => Effect.Effect; - readonly goBack: (tabId: string) => Effect.Effect; - readonly goForward: (tabId: string) => Effect.Effect; - readonly refresh: (tabId: string) => Effect.Effect; - readonly zoomIn: (tabId: string) => Effect.Effect; - readonly zoomOut: (tabId: string) => Effect.Effect; - readonly resetZoom: (tabId: string) => Effect.Effect; - readonly hardReload: (tabId: string) => Effect.Effect; - readonly openDevTools: (tabId: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; - readonly getBrowserPartition: (scope?: string) => Effect.Effect; - readonly setAnnotationTheme: ( - theme: DesktopPreviewAnnotationTheme, - ) => Effect.Effect; - readonly pickElement: ( - tabId: string, - ) => Effect.Effect; - readonly cancelPickElement: (tabId: string) => Effect.Effect; - readonly captureScreenshot: ( - tabId: string, - ) => Effect.Effect; - readonly revealArtifact: (path: string) => Effect.Effect; - readonly copyArtifactToClipboard: (path: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; - readonly stopRecording: (tabId: string) => Effect.Effect; - readonly saveRecording: ( - tabId: string, - mimeType: string, - data: Uint8Array, - ) => Effect.Effect; - readonly automationStatus: ( - tabId: string, - ) => Effect.Effect; - readonly automationSnapshot: ( - tabId: string, - ) => Effect.Effect; - readonly automationClick: ( - tabId: string, - input: PreviewAutomationClickInput, - ) => Effect.Effect; - readonly automationType: ( - tabId: string, - input: PreviewAutomationTypeInput, - ) => Effect.Effect; - readonly automationPress: ( - tabId: string, - input: PreviewAutomationPressInput, - ) => Effect.Effect; - readonly automationScroll: ( - tabId: string, - input: PreviewAutomationScrollInput, - ) => Effect.Effect; - readonly automationEvaluate: ( - tabId: string, - input: PreviewAutomationEvaluateInput, - ) => Effect.Effect; - readonly automationWaitFor: ( - tabId: string, - input: PreviewAutomationWaitForInput, - ) => Effect.Effect; - readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; - readonly subscribePointerEvents: ( - listener: PointerEventListener, - ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; -} - -export class PreviewManager extends Context.Service()( - "@t3tools/desktop/preview/Manager/PreviewManager", -) {} +const isPreviewManagerError = Schema.is(PreviewManagerError); + +export class PreviewManager extends Context.Service< + PreviewManager, + { + readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; + readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect; + readonly closeTab: (tabId: string) => Effect.Effect; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => Effect.Effect; + readonly navigate: (tabId: string, url: string) => Effect.Effect; + readonly goBack: (tabId: string) => Effect.Effect; + readonly goForward: (tabId: string) => Effect.Effect; + readonly refresh: (tabId: string) => Effect.Effect; + readonly zoomIn: (tabId: string) => Effect.Effect; + readonly zoomOut: (tabId: string) => Effect.Effect; + readonly resetZoom: (tabId: string) => Effect.Effect; + readonly hardReload: (tabId: string) => Effect.Effect; + readonly openDevTools: (tabId: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect; + readonly pickElement: ( + tabId: string, + ) => Effect.Effect; + readonly cancelPickElement: (tabId: string) => Effect.Effect; + readonly captureScreenshot: ( + tabId: string, + ) => Effect.Effect; + readonly revealArtifact: (path: string) => Effect.Effect; + readonly copyArtifactToClipboard: (path: string) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; + readonly stopRecording: (tabId: string) => Effect.Effect; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect; + } +>()("@t3tools/desktop/preview/Manager/PreviewManager") {} -const make = Effect.gen(function* PreviewManagerMake() { +export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const operations = yield* makeNativeOperations(environment.browserArtifactsDir); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index 1a4dce14f87..e940ce55906 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -1,14 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection. -import { readFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { runInNewContext } from "node:vm"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); export class PlaywrightInjectedRuntimeError extends Data.TaggedError( @@ -32,7 +32,11 @@ export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRunt catch: (cause) => fail("resolvePackage", cause), }); const coreBundle = yield* Effect.tryPromise({ - try: () => readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"), + try: () => + NodeFSP.readFile( + NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js"), + "utf8", + ), catch: (cause) => fail("readCoreBundle", cause), }); const marker = "source3 = "; @@ -53,7 +57,7 @@ export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRunt } const literal = coreBundle.slice(literalStart, literalEnd); const source = yield* Effect.try({ - try: () => runInNewContext(literal, Object.create(null), { timeout: 1_000 }), + try: () => NodeVM.runInNewContext(literal, Object.create(null), { timeout: 1_000 }), catch: (cause) => fail("evaluateSourceLiteral", cause), }); if (typeof source !== "string" || source.length < 100_000) { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..c76ffa8bbda 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -8,11 +8,6 @@ import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { - DEFAULT_DESKTOP_SETTINGS, - resolveDefaultDesktopSettings, - type DesktopSettings as DesktopSettingsValue, -} from "./DesktopAppSettings.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ @@ -82,20 +77,23 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + assert.deepEqual( + DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopAppSettings.DesktopSettings, + ); }); it.effect("loads persisted settings and applies semantic updates", () => @@ -116,7 +114,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); assert.isTrue(exposure.changed); @@ -137,6 +135,27 @@ describe("DesktopSettings", () => { ), ); + it.effect("reports the failed desktop settings write operation and path", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); + + const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); + assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); + assert.equal(error.operation, "replace-settings-file"); + assert.equal(error.path, environment.desktopSettingsPath); + assert.exists(error.cause); + assert.equal( + error.message, + `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, + ); + }), + ), + ); + it.effect("does not persist no-op semantic updates", () => withSettings( Effect.gen(function* () { @@ -167,7 +186,7 @@ describe("DesktopSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); - assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); }), ), ); @@ -195,7 +214,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); @@ -234,7 +253,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -256,7 +275,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, ), @@ -277,7 +296,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, - } satisfies DesktopSettingsValue); + } satisfies DesktopAppSettings.DesktopSettings); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a54f22fec5b..e072d80f03e 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,50 @@ 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 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 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; -} +const writeError = ( + operation: DesktopSettingsWriteOperation, + path: string, + cause: unknown, +): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause }); 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 +239,86 @@ 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), - ); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); + ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.settingsPath) + .pipe( + Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), + ); }); -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) => + writeError("create-temporary-file-name", 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.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..3584d6a21e4 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"; @@ -34,7 +35,6 @@ const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(Clien 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 68d3fdc904a..d08184f4ab7 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 = ( @@ -75,45 +86,87 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { readonly settingsPath: string; readonly settings: ClientSettings; readonly suffix: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeClientSettingsJson(input.settings); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); + const encoded = yield* encodeClientSettingsJson(input.settings).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), + ); }); -export const layer = Layer.effect( - DesktopClientSettings, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; - return DesktopClientSettings.of({ - get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( - Effect.withSpan("desktop.clientSettings.get"), - ), - set: (settings) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeClientSettings({ - fileSystem, - path, - settingsPath: environment.clientSettingsPath, - settings, - suffix, + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), + set: (settings) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.clientSettingsPath, + cause, }), - ), - Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), - Effect.withSpan("desktop.clientSettings.set"), ), - }); - }), -); + Effect.flatMap((suffix) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + suffix, + }), + ), + Effect.withSpan("desktop.clientSettings.set"), + ), + }); +}); + +export const layer = Layer.effect(DesktopClientSettings, make); export const layerTest = (initialSettings: Option.Option = Option.none()) => Layer.effect( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..abd25a39f5b 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* () { @@ -272,6 +311,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 +348,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 +357,99 @@ describe("DesktopSavedEnvironments", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); + assert.instanceOf( + registryError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); + assert.exists(registryError.cause); + const secretError = yield* savedEnvironments + .getSecret(savedRegistryRecord.environmentId) + .pipe(Effect.flip); + assert.instanceOf( + secretError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, + ); + const mutationError = yield* savedEnvironments + .setRegistry([savedRegistryRecord]) + .pipe(Effect.flip); + assert.instanceOf( + mutationError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); }), ), ); + it.effect("reports saved environment filesystem reads separately from document decoding", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const registryPath = `${baseDir}/userdata/saved-environments.json`; + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: registryPath, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); + assert.equal(error.registryPath, registryPath); + assert.strictEqual(error.cause, permissionError); + assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports the failed saved environment write operation and path", () => + Effect.gen(function* () { + const baseFileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + const permissionError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: `${baseDir}/userdata`, + }); + const fileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: baseFileSystem.readFileString, + makeDirectory: () => Effect.fail(permissionError), + }), + ); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( + Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), + ); + + const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); + assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); + assert.equal(error.operation, "create-directory"); + assert.equal(error.path, `${baseDir}/userdata`); + assert.strictEqual(error.cause, permissionError); + assert.equal( + error.message, + `Desktop saved-environment write failed during create-directory at ${baseDir}/userdata.`, + ); + assert.notEqual(error.message, permissionError.message); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("returns false when writing a secret without metadata", () => withSavedEnvironments( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 531b50ba73b..64c40d39f0e 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,118 @@ 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", +]); +type DesktopSavedEnvironmentsWriteOperation = typeof DesktopSavedEnvironmentsWriteOperation.Type; + +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}.`; + } +} + +const writeError = ( + operation: DesktopSavedEnvironmentsWriteOperation, + path: string, + cause: unknown, +): DesktopSavedEnvironmentsWriteError => + new DesktopSavedEnvironmentsWriteError({ + operation, + path, + cause, + }); + +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 DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( +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 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 type DesktopSavedEnvironmentsReadRegistryError = + | DesktopSavedEnvironmentsReadError + | DesktopSavedEnvironmentsDocumentDecodeError; + +export type DesktopSavedEnvironmentsMutationError = + | DesktopSavedEnvironmentsReadRegistryError + | DesktopSavedEnvironmentsWriteError; + export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError; + | ElectronSafeStorage.ElectronSafeStorageError; 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 + | ElectronSafeStorage.ElectronSafeStorageError; 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 +236,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 +272,23 @@ 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) => writeError("encode-registry", input.registryPath, cause)), + ); + yield* input.fileSystem + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); + yield* input.fileSystem + .writeFileString(tempPath, `${encoded}\n`) + .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); + yield* input.fileSystem + .rename(tempPath, input.registryPath) + .pipe( + Effect.mapError((cause) => writeError("replace-registry-file", input.registryPath, cause)), + ); }, ); @@ -231,129 +314,159 @@ 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, - }), - ), - 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"), +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) => + writeError("create-temporary-file-name", environment.savedEnvironmentRegistryPath, cause), ), - setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { - const currentDocument = yield* readRegistryDocument( - 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( + Effect.flatMap((suffix) => + writeRegistryDocument({ 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) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes( + environmentId, + environment.savedEnvironmentRegistryPath, + 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)); + }), + }; + + 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 +481,18 @@ export const layerTest = (input?: { return DesktopSavedEnvironments.of({ getRegistry: Ref.get(recordsRef), setRegistry: (records) => Ref.set(recordsRef, records), + removeEnvironment: (environmentId) => + Ref.update(recordsRef, (records) => + records.filter((record) => record.environmentId !== environmentId), + ).pipe( + Effect.andThen( + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + ), + ), getSecret: (environmentId) => Ref.get(secretsRef).pipe( Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 897e7336a24..7ec0ab80ae7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,15 +1,23 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; const textEncoder = new TextEncoder(); +const isDesktopShellEnvironmentCommandError = Schema.is( + DesktopShellEnvironment.DesktopShellEnvironmentCommandError, +); + function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -59,16 +67,21 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; + readonly failure?: PlatformError.PlatformError; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ platform: input.platform, - } as DesktopEnvironment.DesktopEnvironmentShape), + } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ChildProcessSpawner.make((command) => + input.failure === undefined + ? Effect.succeed(makeProcess(input.handler(command))) + : Effect.fail(input.failure), + ), ); const program = Effect.gen(function* () { @@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => { ); }), ); + + it.effect("logs command failures with safe probe context and the exact cause", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/bash", + PATH: "/usr/bin", + }; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "/bin/bash", + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return runShellEnvironment({ + env, + platform: "linux", + handler: () => "", + failure: cause, + }).pipe( + Effect.andThen( + Effect.sync(() => { + const errors = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .filter(isDesktopShellEnvironmentCommandError); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.probe, "login-shell"); + assert.equal(errors[0]?.executable, "bash"); + assert.equal(errors[0]?.argumentCount, 2); + assert.notProperty(errors[0] ?? {}, "args"); + assert.equal(errors[0]?.cause, cause); + assert.notInclude(errors[0]?.message ?? "", cause.message); + }), + ), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 13ac35b6297..8219f18b7a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,9 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -19,13 +21,49 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } -export interface DesktopShellEnvironmentShape { - readonly installIntoProcess: Effect.Effect; +const DesktopShellEnvironmentProbe = Schema.Literals([ + "login-shell", + "launchctl-path", + "powershell-profile", + "powershell-no-profile", +]); +type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type; + +const desktopShellEnvironmentCommandFields = { + probe: DesktopShellEnvironmentProbe, + executable: Schema.String, + argumentCount: Schema.Number, +}; + +export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandError", + { + ...desktopShellEnvironmentCommandFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`; + } +} + +export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandTimeoutError", + { + ...desktopShellEnvironmentCommandFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`; + } } export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, - DesktopShellEnvironmentShape + { + readonly installIntoProcess: Effect.Effect; + } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [ @@ -128,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; +const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command; + +const logShellEnvironmentCommandError = ( + error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, +) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-shell-environment", + error, + }), + ); + const capturePosixEnvironmentCommand = (names: ReadonlyArray) => names .map((name) => { @@ -176,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner + const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, @@ -194,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( }), ) .pipe( + Effect.mapError( + (cause) => + new DesktopShellEnvironmentCommandError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + cause, + }), + ), + Effect.catchTags({ + DesktopShellEnvironmentCommandError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.orElseSucceed(() => ""), ); + if (Option.isSome(output)) { + return output.value; + } + + const error = new DesktopShellEnvironmentCommandTimeoutError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + timeoutMs: Duration.toMillis(input.timeout), + }); + yield* logShellEnvironmentCommandError(error); + return ""; }); const readLoginShellEnvironment = ( @@ -207,16 +281,14 @@ const readLoginShellEnvironment = ( names.length === 0 ? Effect.succeed({}) : runCommandOutput({ + probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); -const readLaunchctlPath: Effect.Effect< - Option.Option, - never, - ChildProcessSpawner.ChildProcessSpawner -> = runCommandOutput({ +const readLaunchctlPath = runCommandOutput({ + probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, @@ -239,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ + probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, timeout: LOGIN_SHELL_TIMEOUT, @@ -336,20 +409,20 @@ const installShellEnvironment = ( return Effect.void; }; -export const layer = Layer.effect( - DesktopShellEnvironment, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return DesktopShellEnvironment.of({ - installIntoProcess: installShellEnvironment({ - env: process.env, - platform: environment.platform, - userShell: Option.none(), - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ), - }); - }), -); +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = + installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ); + + return DesktopShellEnvironment.of({ installIntoProcess }); +}); + +export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 77c86be39d2..baed2610286 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -19,6 +19,20 @@ 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", + 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 +118,6 @@ describe("sshEnvironment", () => { Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { request: () => Effect.die("unexpected password prompt request"), resolve: () => Effect.die("unexpected password prompt resolution"), - cancelPending: () => Effect.void, }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 595d3bea304..31e84ae995e 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -4,11 +4,7 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@t3tools/ssh/auth"; +import * as SshAuth from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshCommandError, @@ -19,14 +15,14 @@ import { SshPasswordPromptError, SshReadinessError, } from "@t3tools/ssh/errors"; -import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; @@ -52,27 +48,25 @@ export type DesktopSshEnvironmentError = | DesktopSshEnvironmentDiscoverError | DesktopSshEnvironmentOperationError; -export interface DesktopSshEnvironmentShape { - readonly discoverHosts: (input?: { - readonly homeDir?: string; - }) => Effect.Effect; - readonly ensureEnvironment: ( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) => Effect.Effect; - readonly disconnectEnvironment: ( - target: DesktopSshEnvironmentTarget, - ) => Effect.Effect; -} - export class DesktopSshEnvironment extends Context.Service< DesktopSshEnvironment, - DesktopSshEnvironmentShape + { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: Effect.Effect; + readonly resolveCliRunner?: Effect.Effect; } function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { @@ -88,27 +82,53 @@ export function isDesktopSshPasswordPromptCancellation( ); } +function unexpectedPasswordPromptError(error: never): never { + throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); +} + +export function toSshPasswordPromptError( + cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, +): SshPasswordPromptError { + let message: string; + switch (cause._tag) { + case "DesktopSshPromptRequestIdGenerationError": + message = "Secure randomness is unavailable."; + break; + case "DesktopSshPromptWindowUnavailableError": + case "DesktopSshPromptPresentationError": + message = "T3 Code window is not available for SSH authentication."; + break; + case "DesktopSshPromptTimedOutError": + message = `SSH authentication timed out for ${cause.destination}.`; + break; + case "DesktopSshPromptCancelledError": + message = `SSH authentication cancelled for ${cause.destination}.`; + break; + case "DesktopSshPromptWindowClosedError": + message = "SSH authentication was cancelled because the app window closed."; + break; + case "DesktopSshPromptServiceStoppedError": + message = "SSH password prompt service stopped."; + break; + default: + return unexpectedPasswordPromptError(cause); + } + return new SshPasswordPromptError({ message, cause }); +} + const makePasswordPrompt = ( - prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, -): SshPasswordPromptShape => ({ + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"], +): SshAuth.SshPasswordPrompt["Service"] => ({ isAvailable: true, - request: (request: SshPasswordRequest) => - prompts.request(request).pipe( - Effect.mapError( - (cause) => - new SshPasswordPromptError({ - message: cause.message, - cause, - }), - ), - ), + request: (request: SshAuth.SshPasswordRequest) => + prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -const make = Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; +export const make = Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ discoverHosts: (input) => @@ -120,7 +140,7 @@ const make = Effect.gen(function* () { manager .ensureEnvironment(target, ensureOptions) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.ensureEnvironment"), ), @@ -128,7 +148,7 @@ const make = Effect.gen(function* () { manager .disconnectEnvironment(target) .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), @@ -138,7 +158,7 @@ const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshEnvironmentManager.layer({ + SshTunnel.SshEnvironmentManager.layer({ ...(options.resolveCliPackageSpec === undefined ? {} : { resolveCliPackageSpec: options.resolveCliPackageSpec }), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 080a2fe465d..f0b5b1bd8ef 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 { @@ -111,7 +111,7 @@ describe("DesktopSshPasswordPrompts", () => { 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); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 1d50f9ca325..c933bca3cb0 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,134 @@ 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 WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; + +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() { + { + destination: Schema.String, + }, +) { + override get message(): string { return WINDOW_UNAVAILABLE_MESSAGE; } } -export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ - readonly requestId: string; - readonly destination: string; - readonly cause: unknown; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; +const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); + +export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( + "DesktopSshPromptPresentationError", + { + requestId: Schema.String, + destination: Schema.String, + 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 +147,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 +177,7 @@ interface PendingSshPasswordPrompt { readonly deferred: Deferred.Deferred; } -interface LayerOptions { +export interface DesktopSshPasswordPromptsOptions { readonly passwordPromptTimeoutMs?: number; } @@ -161,14 +201,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 +218,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 +229,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 +251,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,9 +259,9 @@ 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()) { return yield* new DesktopSshPromptWindowUnavailableError({ @@ -233,7 +271,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La 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 +309,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.", }), ), }), @@ -302,36 +343,43 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La return yield* Effect.try({ try: () => { if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } if (window.value.isMinimized()) { window.value.restore(); } if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + throw new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); } window.value.focus(); }, catch: (cause) => - new DesktopSshPromptSendError({ - requestId, - destination: input.destination, - cause, - }), + isDesktopSshPromptWindowUnavailableError(cause) + ? cause + : new DesktopSshPromptPresentationError({ + requestId, + destination: input.destination, + cause, + }), }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({ request, resolve, - cancelPending, }); }); -export const layer = (options: LayerOptions = {}) => +export const layer = (options: DesktopSshPasswordPromptsOptions = {}) => Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77..ad234df0bb5 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -83,7 +83,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,7 +99,7 @@ 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, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e6c81d8d25b..e9142c369e5 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -7,7 +7,6 @@ import type { } 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,23 +59,26 @@ 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() { + { + action: Schema.Literals(["check", "download", "install"]), + }, +) { + override get message(): string { return `Cannot change update tracks while an update ${this.action} action is in progress.`; } } -export class DesktopUpdatePersistenceError extends Data.TaggedError( +export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass()( "DesktopUpdatePersistenceError", -)<{ - readonly cause: DesktopAppSettings.DesktopSettingsWriteError; -}> { - override get message() { - return "Failed to persist desktop update settings."; + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + const detail = this.cause instanceof Error ? this.cause.message : String(this.cause); + return `Failed to persist desktop update settings: ${detail}`; } } @@ -86,22 +88,21 @@ export type DesktopUpdateSetChannelError = | DesktopUpdateActionInProgressError | DesktopUpdatePersistenceError; -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 DesktopUpdates extends Context.Service()( - "@t3tools/desktop/updates/DesktopUpdates", -) {} +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 +128,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect Effect.void, appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, -} satisfies ElectronApp.ElectronAppShape); +} satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialogShape); +} satisfies ElectronDialog.ElectronDialog["Service"]); const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), @@ -64,7 +64,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), -} satisfies DesktopUpdates.DesktopUpdatesShape); +} satisfies DesktopUpdates.DesktopUpdates["Service"]); const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => Layer.succeed(DesktopWindow.DesktopWindow, { @@ -76,7 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendReady: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, - } satisfies DesktopWindow.DesktopWindowShape); + } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( applicationMenuTemplate: Deferred.Deferred, @@ -86,7 +86,7 @@ const makeElectronMenuLayer = ( Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), - } satisfies ElectronMenu.ElectronMenuShape); + } satisfies ElectronMenu.ElectronMenu["Service"]); describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 2d41fa9db86..a52707627b0 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,12 +1,12 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type * as Electron from "electron"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -14,13 +14,23 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; -export interface DesktopApplicationMenuShape { - readonly configure: Effect.Effect; +export class DesktopApplicationMenuActionError extends Schema.TaggedErrorClass()( + "DesktopApplicationMenuActionError", + { + action: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop menu action "${this.action}" failed.`; + } } export class DesktopApplicationMenu extends Context.Service< DesktopApplicationMenu, - DesktopApplicationMenuShape + { + readonly configure: Effect.Effect; + } >()("@t3tools/desktop/window/DesktopApplicationMenu") {} type DesktopApplicationMenuRuntimeServices = @@ -28,9 +38,9 @@ type DesktopApplicationMenuRuntimeServices = | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; -const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); +const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); -const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); +const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, @@ -39,11 +49,7 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); -const checkForUpdatesFromMenu: Effect.Effect< - void, - never, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog -> = Effect.gen(function* () { +const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const result = yield* updates.check("menu"); @@ -67,11 +73,7 @@ const checkForUpdatesFromMenu: Effect.Effect< } }).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); -const handleCheckForUpdatesMenuClick: Effect.Effect< - void, - DesktopWindow.DesktopWindowError, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow -> = Effect.gen(function* () { +const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const disabledReason = yield* updates.disabledReason; @@ -94,7 +96,7 @@ const handleCheckForUpdatesMenuClick: Effect.Effect< yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -110,12 +112,10 @@ const make = Effect.gen(function* () { effect.pipe( Effect.annotateLogs({ action }), Effect.withSpan("desktop.menu.action"), - Effect.catchCause((cause) => - logMenuError("desktop menu action failed", { - action, - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + const error = new DesktopApplicationMenuActionError({ action, cause }); + return logMenuError(error.message, { error }); + }), ), ); }; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e22db07c0cd..76413dd0b55 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -91,7 +91,7 @@ const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { png: Option.none(), }), resolveResourcePath: () => Effect.succeed(Option.none()), -} satisfies DesktopAssets.DesktopAssetsShape); +} satisfies DesktopAssets.DesktopAssets["Service"]); const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), @@ -106,19 +106,19 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), -} satisfies DesktopServerExposure.DesktopServerExposureShape); +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), -} satisfies ElectronMenu.ElectronMenuShape); +} satisfies ElectronMenu.ElectronMenu["Service"]); const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), setSource: () => Effect.void, onUpdated: () => Effect.void, -} satisfies ElectronTheme.ElectronThemeShape); +} satisfies ElectronTheme.ElectronTheme["Service"]); const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( Layer.provide( @@ -156,7 +156,7 @@ function makeTestLayer(input: { sendAll: () => Effect.void, destroyAll: Effect.void, syncAllAppearance: (sync) => sync(input.window), - } satisfies ElectronWindow.ElectronWindowShape); + } satisfies ElectronWindow.ElectronWindow["Service"]); return DesktopWindow.layer.pipe( Layer.provide( @@ -173,7 +173,7 @@ function makeTestLayer(input: { return true; }), copyText: () => Effect.void, - } satisfies ElectronShell.ElectronShellShape), + } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, Layer.mock(PreviewManager.PreviewManager)({ @@ -191,19 +191,19 @@ describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", - navigationUrl: "http://127.0.0.1:3773/settings/connections", + applicationUrl: "t3code://app/", + navigationUrl: "t3code://app/settings/connections", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", + applicationUrl: "t3code://app/", navigationUrl: "https://accounts.microsoft.com/oauth", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "http://127.0.0.1:3773/", + applicationUrl: "t3code://app/", navigationUrl: "not a url", }), ); @@ -231,7 +231,7 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 642abd535ae..e6cfce3c54f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,5 +1,4 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,15 +8,15 @@ import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -32,7 +31,6 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets - | DesktopServerExposure.DesktopServerExposure | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell @@ -40,45 +38,26 @@ type DesktopWindowRuntimeServices = | ElectronWindow.ElectronWindow | PreviewManager.PreviewManager; -export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( - "DesktopWindowDevServerUrlMissingError", -)<{}> { - override get message() { - return "VITE_DEV_SERVER_URL is required in desktop development."; - } -} - export type DesktopWindowError = - | DesktopWindowDevServerUrlMissingError | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; -export interface DesktopWindowShape { - readonly createMain: Effect.Effect; - readonly ensureMain: Effect.Effect; - readonly revealOrCreateMain: Effect.Effect; - readonly activate: Effect.Effect; - readonly createMainIfBackendReady: Effect.Effect; - readonly handleBackendReady: Effect.Effect; - readonly dispatchMenuAction: (action: string) => Effect.Effect; - readonly syncAppearance: Effect.Effect; -} - -export class DesktopWindow extends Context.Service()( - "@t3tools/desktop/window/DesktopWindow", -) {} +export class DesktopWindow extends Context.Service< + DesktopWindow, + { + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; + readonly activate: Effect.Effect; + readonly createMainIfBackendReady: Effect.Effect; + readonly handleBackendReady: Effect.Effect; + readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly syncAppearance: Effect.Effect; + } +>()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = - DesktopObservability.makeComponentLogger("desktop-window"); - -function resolveDesktopDevServerUrl( - environment: DesktopEnvironment.DesktopEnvironmentShape, -): Effect.Effect { - return Option.match(environment.devServerUrl, { - onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), - onSome: (url) => Effect.succeed(url.href), - }); -} + makeComponentLogger("desktop-window"); function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, @@ -163,7 +142,7 @@ function bindFirstRevealTrigger( } } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; @@ -171,18 +150,16 @@ const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - const createWindow = Effect.fn("desktop.window.createWindow")(function* ( - backendHttpUrl: URL, - ): Effect.fn.Return { + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< + Electron.BrowserWindow, + DesktopWindowError + > { yield* previewManager.getBrowserSession(); - const applicationUrl = environment.isDevelopment - ? yield* resolveDesktopDevServerUrl(environment) - : backendHttpUrl.href; + const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -350,8 +327,7 @@ const make = Effect.gen(function* () { }); const createMain = Effect.gen(function* () { - const backendConfig = yield* serverExposure.backendConfig; - const window = yield* createWindow(backendConfig.httpBaseUrl); + const window = yield* createWindow(); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); return window; @@ -404,7 +380,7 @@ const make = Effect.gen(function* () { const send = () => { if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); void runPromise(electronWindow.reveal(targetWindow)); }; diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index dceefc14e9e..96e089b9183 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -56,6 +56,12 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, entry: ["src/preload.ts"], + deps: { + // Sandboxed Electron preloads cannot reliably resolve package imports + // from inside the packaged ASAR. Bundle Clerk's preload bridge into the + // preload artifact instead of leaving a runtime require() behind. + alwaysBundle: (id) => id === "@clerk/electron" || id.startsWith("@clerk/electron/"), + }, }, { format: "cjs", diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index e60637cbfd1..5d9fc4e8f3b 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,4 +1,6 @@ --- +import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; + interface Props { title?: string; description?: string; @@ -36,17 +38,17 @@ const { @@ -62,7 +64,7 @@ const { © {new Date().getFullYear()} T3 Tools Inc · MIT licensed @@ -329,23 +331,36 @@ const { gap: 8px; } - .nav-gh { + .nav-stars { display: inline-flex; align-items: center; - gap: 6px; - padding: 7px 12px; + gap: 7px; + height: 36px; + padding: 0 14px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.02); color: var(--fg-muted); - font-family: var(--font-mono); - font-size: 12px; - transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease; + font-size: 13px; + letter-spacing: -0.01em; + white-space: nowrap; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease; } - .nav-gh:hover { + .nav-stars:hover { color: var(--fg); - background: rgba(255, 255, 255, 0.04); border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.04); + } + + .nav-stars strong { + color: var(--fg); + font-weight: 600; + } + + .nav-stars svg { + color: var(--warn); + flex-shrink: 0; } .main { @@ -407,4 +422,17 @@ const { padding-right: 20px; } } + + @media (max-width: 420px) { + .nav-inner { + gap: 12px; + } + + .nav-stars { + height: 34px; + gap: 6px; + padding: 0 12px; + font-size: 12px; + } + } diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts new file mode 100644 index 00000000000..5ff5958c588 --- /dev/null +++ b/apps/marketing/src/lib/site.ts @@ -0,0 +1,6 @@ +export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; + +export const MARKETING_STATS = { + githubStars: "12k+", + users: "100,000", +} as const; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 0b76f350896..69de2088d43 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1,5 +1,6 @@ --- import Layout from "../layouts/Layout.astro"; +import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; import { tweets } from "../lib/tweets"; const desktopEndorsementRows = [ @@ -55,11 +56,12 @@ const mobileEndorsementRows = [ Download for macOS - - Steal our code (legally) + @@ -82,8 +84,8 @@ const mobileEndorsementRows = [
-

Developers love T3 Code

-

Real reactions from people building with T3 Code today.

+

Tolerated by over {MARKETING_STATS.users} devs

+

Some of them even tweeted about it.

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

If you don't like something, fork it.

-

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

@@ -305,43 +303,44 @@ const mobileEndorsementRows = [
-
-
-
MIT
-
License · commercial-friendly
-
-
-
TypeScript
-
End-to-end, strictly typed
+
+
+
    +
  • + + Change the UI. Restyle every surface to match your taste. +
  • +
  • + + Add an agent. Wire in your own tools, models, and flows. +
  • +
  • + + Ship your own build. Self-host it or distribute it as your own. +
  • +
-
-
1 monorepo
-
Desktop · web · server · harnesses
-
-
-
No telemetry
-
Unless you opt in. Full stop.
+ +
- -
@@ -515,7 +514,7 @@ const mobileEndorsementRows = [ .hero-title { font-size: clamp(38px, 5.6vw, 76px); - margin: 28px auto 22px; + margin: 48px auto 22px; max-width: 20ch; text-wrap: balance; } @@ -531,12 +530,42 @@ const mobileEndorsementRows = [ .hero-actions { display: flex; - gap: 10px; - justify-content: center; - flex-wrap: wrap; + flex-direction: column; + align-items: center; + gap: 16px; margin-bottom: 56px; } + .hero-source-link { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg-muted); + font-size: 14px; + font-weight: 500; + letter-spacing: -0.01em; + transition: color 0.18s ease; + } + + .hero-source-link:hover { + color: var(--fg); + } + + .hero-source-mark { + flex-shrink: 0; + } + + .hero-source-arrow { + flex-shrink: 0; + opacity: 0.6; + transition: transform 0.18s ease, opacity 0.18s ease; + } + + .hero-source-link:hover .hero-source-arrow { + transform: translate(2px, -2px); + opacity: 1; + } + /* Download button icons (platform-aware) */ .dl-icon { display: none; @@ -656,6 +685,30 @@ const mobileEndorsementRows = [ } } + @media (max-width: 340px) { + .hero-float-mark.hf-opencode, + .hero-float-mark.hf-cursor { + top: 580px; + width: 52px; + height: 52px; + border-radius: 14px; + } + + .hero-float-mark.hf-opencode { + left: 0; + } + + .hero-float-mark.hf-cursor { + right: 0; + } + + .hero-float-mark.hf-opencode img, + .hero-float-mark.hf-cursor img { + width: 30px; + height: 30px; + } + } + .hero-preview { max-width: 1180px; margin: 0 auto; @@ -759,6 +812,11 @@ const mobileEndorsementRows = [ margin-bottom: 18px; } + .endorsements-count { + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .endorsements-head p { color: var(--fg-muted); font-size: 18px; @@ -962,12 +1020,11 @@ const mobileEndorsementRows = [ text-align: center; margin: 0 auto 56px; } - .open-head p { margin: 0 auto; } .open-grid { display: grid; grid-template-columns: 1.25fr 1fr; - gap: 20px; margin-bottom: 32px; + gap: 20px; } .open-term { padding: 0; overflow: hidden; } @@ -1009,27 +1066,85 @@ const mobileEndorsementRows = [ animation: blink 1s steps(2) infinite; } - .open-stats { - display: grid; - grid-template-columns: 1fr 1fr; + .open-pitch { + padding: 32px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 32px; + background: + radial-gradient(110% 75% at 100% 0%, var(--accent-dim), transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); + } + + .open-pitch-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 16px; + } + + .open-pitch-list li { + display: flex; + align-items: flex-start; gap: 12px; + font-size: 15px; + line-height: 1.5; + color: var(--fg-muted); + } + + .open-pitch-list strong { + color: var(--fg); + font-weight: 600; + } + + .open-pitch-mark { + flex: none; + display: grid; + place-items: center; + width: 22px; + height: 22px; + margin-top: 1px; + border-radius: 7px; + color: var(--accent); + background: var(--accent-dim); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); } - .open-stat { - padding: 22px; - display: flex; flex-direction: column; gap: 6px; + + .open-pitch-footer { + display: flex; + flex-direction: column; + gap: 18px; } - .open-stat-val { - font-size: 22px; font-weight: 500; - letter-spacing: -0.015em; + + .open-pitch-meta { + display: flex; + align-items: center; + gap: 8px; + color: var(--fg-dim); + font-family: var(--font-mono); + font-size: 11px; } - .open-stat-lbl { - font-family: var(--font-mono); font-size: 10.5px; - color: var(--fg-dim); letter-spacing: 0.04em; + + .open-pitch-actions { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; } - .open-ctas { - display: flex; gap: 10px; - justify-content: center; flex-wrap: wrap; + .open-source-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); + font-size: 13px; + font-weight: 500; + transition: color 0.18s ease; + } + + .open-source-link:hover { + color: var(--fg); } /* ── Final CTA ────────────────────────────────────────── */ diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index 83fc429b731..0714ce90e63 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -1,5 +1,6 @@ included: - ios/T3Code + - modules/t3-composer-editor/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7cbb8335deb..8cdf6f2e25c 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -131,6 +131,11 @@ const config: ExpoConfig = { { ios: { deploymentTarget: "18.0", + // AppCheckCore 11.3+ includes Swift and needs module maps for these Objective-C dependencies. + extraPods: [ + { name: "GoogleUtilities", modular_headers: true }, + { name: "RecaptchaInterop", modular_headers: true }, + ], }, }, ], diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 4e6b55a4223..14c5ea58669 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -1,7 +1,8 @@ { "cli": { "version": ">= 18.4.0", - "appVersionSource": "remote" + "appVersionSource": "remote", + "promptToConfigurePushNotifications": false }, "build": { "development": { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 0fbf4fb3c9d..b2014bf9353 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -192,11 +192,31 @@ } } -/* ─── Font family ───────────────────────────────────────────────────── */ +/* ─── Typography ────────────────────────────────────────────────────── */ @theme { --font-sans: "DMSans_400Regular"; --font-medium: "DMSans_500Medium"; --font-bold: "DMSans_700Bold"; + + /* Keep this scale aligned with src/lib/typography.ts for native style props. */ + --text-3xs: 10px; + --text-3xs--line-height: 13px; + --text-2xs: 11px; + --text-2xs--line-height: 15px; + --text-xs: 12px; + --text-xs--line-height: 16px; + --text-sm: 13px; + --text-sm--line-height: 18px; + --text-base: 15px; + --text-base--line-height: 22px; + --text-lg: 17px; + --text-lg--line-height: 22px; + --text-xl: 20px; + --text-xl--line-height: 26px; + --text-2xl: 24px; + --text-2xl--line-height: 30px; + --text-3xl: 28px; + --text-3xl--line-height: 34px; } /* ─── Custom utilities ──────────────────────────────────────────────── */ diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index a88acbc31f7..6f4dc575b12 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -275,8 +275,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { fileTint: "#737373" ) private var fontFamily = "DMSans_400Regular" - private var fontSize: CGFloat = 15 - private var lineHeight: CGFloat = 22 + private var fontSize: CGFloat = 14 + private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 private var shouldAutoFocus = false private var didAutoFocus = false @@ -460,9 +460,19 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { guard !isApplyingControlledValue else { return } + restoreBaseTypingAttributes() emitSelection() } + public func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + restoreBaseTypingAttributes() + return true + } + public func textViewDidBeginEditing(_ textView: UITextView) { onComposerFocus() } @@ -484,6 +494,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { let targetSelection = requestedSelection ?? previousSelection requestedSelection = nil textView.selectedRange = displayRange(for: targetSelection) + restoreBaseTypingAttributes() isApplyingControlledValue = false updatePlaceholderVisibility() emitContentSizeIfNeeded() @@ -556,7 +567,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { size: image.size, baselineOffset: baselineOffset ) - return NSAttributedString(attachment: attachment) + let attributedAttachment = NSMutableAttributedString(attachment: attachment) + attributedAttachment.addAttributes( + baseAttributes(), + range: NSRange(location: 0, length: attributedAttachment.length) + ) + return attributedAttachment } private func renderChip( @@ -660,11 +676,18 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { let font = UIFont(name: fontFamily, size: fontSize) ?? UIFont.systemFont(ofSize: fontSize) textView.font = font - textView.typingAttributes = baseAttributes() + restoreBaseTypingAttributes() placeholderLabel.font = font setNeedsLayout() } + private func restoreBaseTypingAttributes() { + guard textView.markedTextRange == nil else { + return + } + textView.typingAttributes = baseAttributes() + } + private func applyTheme() { textView.textColor = UIColor(composerHex: theme.text) ?? .label placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 3ebfdb7a11e..6fa61aab17e 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments( renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; - attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); + attachment.bounds = CGRectMake( + 0, + T3MarkdownTextAttachmentBaselineOffset(attachmentRange), + attachmentSize, + attachmentSize); const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); @@ -80,104 +85,6 @@ static void T3MarkdownTextApplyAttachments( } } -static NSArray *> *T3MarkdownTextExtractChipBackgrounds( - NSMutableAttributedString *attributedString, - const std::vector &chipRanges) -{ - NSMutableArray *> *backgrounds = [NSMutableArray array]; - for (const auto &chipRange : chipRanges) { - if (chipRange.length == 0 || chipRange.location >= attributedString.length) { - continue; - } - - const NSRange range = NSMakeRange( - chipRange.location, - MIN(chipRange.length, attributedString.length - chipRange.location)); - UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName - atIndex:range.location - effectiveRange:nil]; - UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName - atIndex:range.location - effectiveRange:nil]; - if (color == nil) { - continue; - } - [backgrounds addObject:@{ - @"range": [NSValue valueWithRange:range], - @"color": color, - @"strokeColor": [foregroundColor - colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, - }]; - [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; - } - return backgrounds; -} - -@interface T3MarkdownTextBackingView : UITextView -@property(nonatomic, copy) NSArray *> *chipBackgrounds; -@end - -@implementation T3MarkdownTextBackingView - -- (void)drawRect:(CGRect)rect -{ - [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; - CGContextRef context = UIGraphicsGetCurrentContext(); - if (context != nil) { - CGContextSaveGState(context); - CGContextResetClip(context); - CGContextClipToRect(context, self.bounds); - } - for (NSDictionary *background in self.chipBackgrounds) { - const NSRange characterRange = [background[@"range"] rangeValue]; - UIColor *color = background[@"color"]; - UIColor *strokeColor = background[@"strokeColor"]; - if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { - continue; - } - - const NSRange glyphRange = - [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; - [color setFill]; - [self.layoutManager - enumerateEnclosingRectsForGlyphRange:glyphRange - withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) - inTextContainer:self.textContainer - usingBlock:^(CGRect glyphRect, BOOL *stop) { - const CGFloat chipHeight = 22; - CGRect chipRect = CGRectMake( - glyphRect.origin.x - 4, - CGRectGetMidY(glyphRect) - chipHeight / 2, - glyphRect.size.width + 8, - chipHeight); - chipRect.origin.x += self.textContainerInset.left; - chipRect.origin.y += self.textContainerInset.top; - const CGFloat minimumX = self.textContainerInset.left + 0.5; - const CGFloat maximumX = - CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; - if (chipRect.origin.x < minimumX) { - chipRect.size.width -= minimumX - chipRect.origin.x; - chipRect.origin.x = minimumX; - } - if (CGRectGetMaxX(chipRect) > maximumX) { - chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); - } - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; - [path fill]; - [strokeColor setStroke]; - path.lineWidth = 1; - [path stroke]; - }]; - } - if (context != nil) { - CGContextRestoreGState(context); - } - - [super drawRect:rect]; -} - -@end - @protocol T3MarkdownOutsideTapTarget - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; @end @@ -285,7 +192,7 @@ @interface T3MarkdownText () @implementation T3MarkdownText { UIView * _view; - T3MarkdownTextBackingView * _textView; + UITextView * _textView; T3MarkdownTextShadowNode::ConcreteState::Shared _state; __weak UIWindow * _outsideTapWindow; BOOL _suppressSelectionChange; @@ -308,7 +215,7 @@ - (instancetype)initWithFrame:(CGRect)frame self.contentView = _view; self.clipsToBounds = true; - _textView = [[T3MarkdownTextBackingView alloc] init]; + _textView = [[UITextView alloc] init]; _attachmentImages = [[NSMutableDictionary alloc] init]; _pendingAttachmentUris = [[NSMutableSet alloc] init]; _textView.scrollEnabled = false; @@ -405,9 +312,6 @@ - (void)drawRect:(CGRect)rect convertedAttrString, _state->getData().attachmentRanges, _attachmentImages); - _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( - convertedAttrString, - _state->getData().chipRanges); [self loadAttachmentImages:_state->getData().attachmentRanges]; // Setting attributedText clears any active text selection, and re-assigning diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index afc276aedda..99417490a63 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -28,18 +28,20 @@ struct T3MarkdownTextAttachmentRange { std::string imageUri; }; -struct T3MarkdownTextChipRange { - size_t location; - size_t length; - bool isSkill; -}; +inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { + return 14; +} + +inline Float T3MarkdownTextAttachmentBaselineOffset( + const T3MarkdownTextAttachmentRange &) { + return -2; +} class T3MarkdownTextStateReal final { public: AttributedString attributedString; std::vector paragraphStyleRanges; std::vector attachmentRanges; - std::vector chipRanges; }; class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< @@ -72,6 +74,5 @@ T3MarkdownTextStateReal> { mutable AttributedString _attributedString; mutable std::vector _paragraphStyleRanges; mutable std::vector _attachmentRanges; - mutable std::vector _chipRanges; }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index 00fda742284..b9abe452fb9 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -9,9 +9,8 @@ namespace facebook::react { static constexpr Float ParagraphStyleEncodingOffset = 1000; -static constexpr auto ChipNativeIdPrefix = "t3-chip-"; -static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; -static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; +static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; +static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -58,7 +57,12 @@ static void applyAttachments( NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; attachment.image = [[UIImage alloc] init]; - attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); + attachment.bounds = CGRectMake( + 0, + T3MarkdownTextAttachmentBaselineOffset(attachmentRange), + attachmentSize, + attachmentSize); const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); @@ -91,7 +95,6 @@ static void applyAttachments( auto baseAttributedString = AttributedString{}; auto paragraphStyleRanges = std::vector{}; auto attachmentRanges = std::vector{}; - auto chipRanges = std::vector{}; size_t utf16Offset = 0; const auto &children = getChildren(); for (size_t i = 0; i < children.size(); i++) { @@ -184,25 +187,19 @@ static void applyAttachments( props.shadowRadius - ParagraphStyleEncodingOffset, }); } - if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - chipRanges.push_back(T3MarkdownTextChipRange{ - utf16Offset, - fragmentLength, - props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, - }); - } - if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset + 1, + utf16Offset, 1, - props.nativeId.substr(std::char_traits::length(FileChipNativeIdPrefix)), + props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), }); } else if ( - props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset + 1, + utf16Offset, 1, - props.nativeId.substr(std::char_traits::length(SkillChipNativeIdPrefix)), + props.nativeId.substr( + std::char_traits::length(SkillAttachmentNativeIdPrefix)), }); } utf16Offset += fragmentLength; @@ -213,7 +210,6 @@ static void applyAttachments( _attributedString = baseAttributedString; _paragraphStyleRanges = paragraphStyleRanges; _attachmentRanges = attachmentRanges; - _chipRanges = chipRanges; NSMutableAttributedString *convertedAttributedString = [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; @@ -263,7 +259,6 @@ static void applyAttachments( _attributedString, _paragraphStyleRanges, _attachmentRanges, - _chipRanges, }); } } diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs index 87f17c28e0f..2c2cc43bc65 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -1,16 +1,19 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import { getBuiltInSpriteSheet } from "@pierre/trees"; -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const moduleDirectory = resolve(scriptDirectory, ".."); -const repositoryRoot = resolve(moduleDirectory, "../../../.."); -const outputDirectory = join(moduleDirectory, "assets/file-icons"); -const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts"); -const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8"); +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const moduleDirectory = NodePath.resolve(scriptDirectory, ".."); +const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../.."); +const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons"); +const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts"); +const webIconSource = NodeFS.readFileSync( + NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), + "utf8", +); const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; if (!customSprite) { @@ -95,20 +98,20 @@ function symbolFromSprite(sprite, id) { } function renderIcon(token, symbol, color) { - const svgPath = join(outputDirectory, `.pierre-${token}.svg`); - const pngPath = join(outputDirectory, `pierre_${token}.png`); - writeFileSync( + const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`); + const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`); + NodeFS.writeFileSync( svgPath, `${symbol.body}`, ); - execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { + NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { stdio: "ignore", }); - rmSync(svgPath); + NodeFS.rmSync(svgPath); } -rmSync(outputDirectory, { recursive: true, force: true }); -mkdirSync(outputDirectory, { recursive: true }); +NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); +NodeFS.mkdirSync(outputDirectory, { recursive: true }); const builtInSprite = getBuiltInSpriteSheet("complete"); const builtInTokens = [...builtInSprite.matchAll(/ ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) .join("\n")}\n} as const satisfies Readonly>;\n`; -writeFileSync(generatedModulePath, generatedSource); +NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 757b6c66011..212c385124e 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -40,11 +40,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { function SelectableNode(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { return ( ); } @@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { function NativeTable(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const rows = collectTableRows(props.node); return ( @@ -351,6 +354,7 @@ function NativeTable(props: { rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} + onLinkPress={props.onLinkPress} /> ))} @@ -364,10 +368,17 @@ function NativeTable(props: { function NativeMarkdownImage(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const href = props.node.href; if (!href) { - return ; + return ( + + ); } return ( @@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { function NativeMixedParagraph(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { return ( @@ -435,9 +447,15 @@ function NativeMixedParagraph(props: { key={nodeKey(child, index)} node={child} textStyle={props.textStyle} + onLinkPress={props.onLinkPress} /> ) : ( - + ), )} @@ -448,6 +466,7 @@ function NativeList(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; readonly depth: number; }) { const ordered = props.node.ordered ?? false; @@ -508,6 +527,7 @@ function NativeList(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={props.depth + 1} compact /> @@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; readonly depth?: number; readonly compact?: boolean; }) { @@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} /> ))} @@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: { /> ); case "table": - return ; + return ( + + ); case "image": - return ; + return ( + + ); case "horizontal_rule": return ( @@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: { node={props.node} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} /> ); case "paragraph": return (props.node.children ?? []).some((child) => child.type === "image") ? ( - + ) : ( - + ); case "html_block": case "math_block": @@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: { : "transparent", }} > - + ); case "table_head": @@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: { node={child} textStyle={props.textStyle} highlightCode={props.highlightCode} + onLinkPress={props.onLinkPress} depth={depth} compact /> @@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: { ); default: - return ; + return ( + + ); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index c6495eed860..c7a5a16d6fd 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -1,61 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; -import { Asset } from "expo-asset"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; -import type { MarkdownFileIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; const EXTERNAL_LINK_PREFIX = "◉ "; -const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; -const CHIP_SUFFIX = "\u00A0"; +const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; const SKILL_ICON_PLACEHOLDER = "\uFFFC"; const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; -function useFileIconUris(runs: ReadonlyArray) { - const iconSignature = JSON.stringify( - [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), - ); - const icons = useMemo( - () => JSON.parse(iconSignature) as ReadonlyArray, - [iconSignature], - ); - const [uris, setUris] = useState>(() => new Map()); - - useEffect(() => { - let cancelled = false; - - void Promise.all( - icons.map(async (icon) => { - const source = markdownFileIconSource(icon); - const fallbackUri = Image.resolveAssetSource(source).uri; - if (typeof source !== "number" && typeof source !== "string") { - return [icon, fallbackUri] as const; - } - try { - const asset = Asset.fromModule(source); - await asset.downloadAsync(); - return [icon, asset.localUri ?? fallbackUri] as const; - } catch { - return [icon, fallbackUri] as const; - } - }), - ).then((entries) => { - if (!cancelled) { - setUris(new Map(entries)); - } - }); - - return () => { - cancelled = true; - }; - }, [icons]); - - return uris; -} - function runKeySignature(run: NativeMarkdownTextRun): string { return [ run.text, @@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string { function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { const isFile = run.fileIcon != null; const isSkill = run.skillName != null; - const isChip = isFile || isSkill; const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; const isHeading = run.role === "heading"; const isCodeBlock = run.role === "code-block" || run.role === "code-language"; const hasParagraphStyle = run.headIndent !== undefined; - const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; + const textDecorationLine = run.strikethrough + ? "line-through" + : run.href && !isFile + ? "underline" + : "none"; return { color: isFile @@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? textStyle.mutedColor : run.role === "list-marker" ? textStyle.mutedColor - : run.code || isFile + : isCodeBlock ? textStyle.codeColor - : run.bold - ? textStyle.strongColor - : textStyle.color, - fontFamily: isChip - ? "DMSans_500Medium" - : run.code || isCodeBlock - ? "ui-monospace" - : isHeading - ? textStyle.headingFontFamily - : run.bold - ? textStyle.boldFontFamily - : textStyle.fontFamily, + : run.code + ? textStyle.inlineCodeColor + : run.bold + ? textStyle.strongColor + : textStyle.color, + fontFamily: + isFile || isSkill + ? textStyle.boldFontFamily + : run.code || isCodeBlock + ? "ui-monospace" + : isHeading + ? textStyle.headingFontFamily + : run.bold + ? textStyle.boldFontFamily + : textStyle.fontFamily, fontSize: run.role === "spacer" ? (run.spacing ?? 10) @@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? headingFontSize : run.role === "code-language" ? 11 - : run.code || isChip || isCodeBlock + : run.code || isCodeBlock ? Math.max(12, textStyle.fontSize - 2) : textStyle.fontSize, lineHeight: @@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle ? 18 : textStyle.lineHeight, fontStyle: run.italic ? "italic" : "normal", - fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", + fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", textDecorationLine, - backgroundColor: isCodeBlock - ? textStyle.codeBlockBackgroundColor - : isSkill - ? textStyle.skillBackgroundColor - : run.code - ? textStyle.codeBackgroundColor - : isFile - ? textStyle.fileBackgroundColor - : undefined, + backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined, ...(hasParagraphStyle ? { shadowColor: "transparent", @@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; }) { const colorScheme = useColorScheme(); - const fileIconUris = useFileIconUris(props.runs); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: { let text = run.text; if (run.fileIcon) { - text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { - text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; + text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); text = `${EXTERNAL_LINK_PREFIX}${text}`; @@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: { props.textStyle.strongColor, props.textStyle.mutedColor, props.textStyle.linkColor, + props.textStyle.inlineCodeColor, props.textStyle.codeColor, props.textStyle.codeBackgroundColor, props.textStyle.codeBlockBackgroundColor, - props.textStyle.fileBackgroundColor, props.textStyle.fileTextColor, - props.textStyle.skillBackgroundColor, props.textStyle.skillTextColor, props.textStyle.quoteMarkerColor, props.textStyle.dividerColor, @@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: { uiTextView selectable style={{ - width: "100%", + flexShrink: 1, + minWidth: 0, color: props.textStyle.color, fontFamily: props.textStyle.fontFamily, fontSize: props.textStyle.fontSize, @@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: { key={key} nativeID={ run.fileIcon - ? `t3-chip-file:${ - fileIconUris.get(run.fileIcon) ?? - Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri - }` + ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName - ? "t3-chip-skill:sf:cube" + ? "t3-skill:sf:cube" : undefined } style={runStyle(run, props.textStyle)} onPress={ href ? () => { - void Linking.openURL(href); + if (props.onLinkPress) { + props.onLinkPress(href); + } else { + void Linking.openURL(href); + } } : undefined } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 7c8f8d1bd55..56321ba01ad 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -6,6 +6,7 @@ import { nativeMarkdownChunkSpacing, nativeMarkdownDocumentChunks, nativeMarkdownDocumentRuns, + nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; @@ -33,15 +34,20 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + preserveSoftBreaks = false, + onLinkPress, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { const chunks = useMemo(() => { - const document = parseMarkdownWithOptions(markdown, { + const parsedDocument = parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false, }); + const document = preserveSoftBreaks + ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) + : parsedDocument; return nativeMarkdownDocumentChunks(document).map((chunk) => chunk.kind === "selectable" ? { @@ -50,10 +56,14 @@ export function SelectableMarkdownText({ } : chunk, ); - }, [markdown, skills]); + }, [markdown, preserveSoftBreaks, skills]); return ( - + // A percentage width here creates a cyclic intrinsic measurement inside + // shrink-to-fit containers such as user-message bubbles. Yoga then gives + // the native text node an unbounded second pass and the parent only clips + // the resulting single-line width instead of reflowing it. + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( @@ -61,9 +71,14 @@ export function SelectableMarkdownText({ node={chunk.node} textStyle={textStyle} highlightCode={highlightCode} + onLinkPress={onLinkPress} /> ) : ( - + ); return ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index bd67d9110e5..76c1402d3c8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle { readonly strongColor: string; readonly mutedColor: string; readonly linkColor: string; + readonly inlineCodeColor: string; readonly codeColor: string; readonly codeBackgroundColor: string; readonly codeBlockBackgroundColor: string; - readonly fileBackgroundColor: string; readonly fileTextColor: string; - readonly skillBackgroundColor: string; readonly skillTextColor: string; readonly quoteMarkerColor: string; readonly dividerColor: string; @@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps { readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly skills?: ReadonlyArray; + readonly preserveSoftBreaks?: boolean; + readonly onLinkPress?: (href: string) => void; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index affd7515b25..f13891e3ff8 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -27,8 +27,12 @@ export type MarkdownLinkPresentation = } | { readonly kind: "file"; + readonly href: string; readonly icon: MarkdownFileIcon; readonly label: string; + readonly path: string; + readonly line?: number; + readonly column?: number; } | { readonly kind: "link"; @@ -247,7 +251,7 @@ function normalizeDestination(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } -function fileUrlPath(href: string): string | null { +function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { @@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null { const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) ? parsed.pathname.slice(1) : parsed.pathname; - const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); - return `${safeDecode(path)}${ - lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" - }`; + return { path, hash: parsed.hash }; } catch { return null; } } +function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { + const hashIndex = value.indexOf("#"); + const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; + const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; + const queryIndex = pathWithSearch.indexOf("?"); + return { + path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, + hash, + }; +} + +function splitFilePosition( + path: string, + hash: string, +): { readonly path: string; readonly line?: number; readonly column?: number } { + const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); + const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); + const match = suffixMatch ?? hashMatch; + if (!match?.[1]) { + return { path }; + } + + const line = Number.parseInt(match[1], 10); + const column = match[2] ? Number.parseInt(match[2], 10) : undefined; + const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; + return { + path: pathWithoutPosition, + ...(line > 0 ? { line } : {}), + ...(column !== undefined && column > 0 ? { column } : {}), + }; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) { return false; @@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese // Relative paths and non-URL link destinations are handled below. } - const fileTarget = normalized.toLowerCase().startsWith("file:") - ? fileUrlPath(normalized) - : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); - if (fileTarget && looksLikeFilePath(fileTarget)) { + const source = normalized.toLowerCase().startsWith("file:") + ? fileUrlTarget(normalized) + : stripSearchAndHash(normalized); + const decodedSource = source + ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } + : null; + const fileTarget = decodedSource + ? splitFilePosition(decodedSource.path, decodedSource.hash) + : null; + const targetWithPosition = fileTarget + ? `${fileTarget.path}${ + fileTarget.line + ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` + : "" + }` + : null; + if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { return { kind: "file", - icon: resolveMarkdownFileIcon(fileTarget), - label: fileLabel(fileTarget), + href: normalized, + icon: resolveMarkdownFileIcon(fileTarget.path), + label: fileLabel(targetWithPosition), + path: fileTarget.path, + ...(fileTarget.line ? { line: fileTarget.line } : {}), + ...(fileTarget.column ? { column: fileTarget.column } : {}), }; } diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 6751e165f1c..dc84755cbbd 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -293,6 +293,7 @@ function appendNode( if (presentation.kind === "file") { return appendRun(runs, presentation.label, { ...context, + href: presentation.href, fileIcon: presentation.icon, }); } @@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray CGFloat { + guard let value, value.isFinite, value >= 0 else { + return fallback + } + return CGFloat(value) + } + private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight { switch value?.lowercased() { case "ultralight", "ultra-light": @@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var lastMetricsDebugKey = "" private var lastVisibleRangeDebugKey = "" private var tokensResetKey = "" + private var initialRowIndex: Int? + private var hasAppliedInitialRowIndex = false let onDebug = EventDispatcher() let onToggleFile = EventDispatcher() @@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { do { rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) contentView.rows = rows + hasAppliedInitialRowIndex = false emitDebug("rows-decoded", [ "rows": rows.count, "firstKind": rows.first?.kind ?? "none", @@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } catch { rows = [] contentView.rows = [] + hasAppliedInitialRowIndex = false updateContentMetrics() emitDebug("rows-decode-failed", [ "error": error.localizedDescription, @@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() + applyInitialRowIndexIfNeeded() let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" if debugKey != lastMetricsDebugKey { @@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyStyle() } + func setInitialRowIndex(_ initialRowIndex: Double) { + let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0 + ? Int(initialRowIndex.rounded(.down)) + : nil + guard nextIndex != self.initialRowIndex else { + return + } + + self.initialRowIndex = nextIndex + hasAppliedInitialRowIndex = false + applyInitialRowIndexIfNeeded() + } + private func applyStyle() { contentView.style = ReviewDiffNativeStyle .resolve(stylePayload) @@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() } + + private func applyInitialRowIndexIfNeeded() { + guard !hasAppliedInitialRowIndex, + let initialRowIndex, + bounds.height > 0, + let rowFrame = contentView.frameForRow(at: initialRowIndex) else { + return + } + + let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) + let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) + let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + hasAppliedInitialRowIndex = true + scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) + updateViewportFrame() + } } private enum ReviewDiffHorizontalPanKind { @@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + func frameForRow(at index: Int) -> CGRect? { + guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { + return nil + } + + return CGRect( + x: 0, + y: rowOffsets[index], + width: max(viewportWidth, 1), + height: height(for: rows[index]) + ) + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 47efc95adb1..ddf5b2a0250 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.4.1", + "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", @@ -49,10 +49,10 @@ "@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", @@ -77,6 +77,7 @@ "expo-haptics": "~56.0.3", "expo-image-picker": "~56.0.14", "expo-linking": "~56.0.12", + "expo-network": "~56.0.5", "expo-notifications": "~56.0.14", "expo-paste-input": "^0.1.15", "expo-router": "~56.2.7", @@ -98,10 +99,11 @@ "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" }, diff --git a/apps/mobile/src/app/+not-found.tsx b/apps/mobile/src/app/+not-found.tsx index 124077b0909..d11155f8602 100644 --- a/apps/mobile/src/app/+not-found.tsx +++ b/apps/mobile/src/app/+not-found.tsx @@ -21,7 +21,7 @@ export default function NotFoundRoute() { }} style={[{ flex: 1 }, screenBgStyle]} > - + Route not found @@ -35,7 +35,7 @@ export default function NotFoundRoute() { primaryBgStyle, ]} > - Return home + Return home diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1583fdbb2d7..968be6c14a8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -16,10 +16,8 @@ import { useResolveClassNames } from "uniwind"; import { LoadingScreen } from "../components/LoadingScreen"; -import { - useRemoteEnvironmentBootstrap, - useRemoteEnvironmentState, -} from "../state/use-remote-environment-registry"; +import { useWorkspaceState } from "../state/workspace"; +import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; @@ -32,22 +30,24 @@ import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { const pathname = usePathname(); - const clerkRouteIsActive = pathname === "/settings/auth"; + const expandedSettingsRouteIsActive = + pathname === "/settings/archive" || pathname === "/settings/auth"; return ( - + ); } function AppNavigatorContent() { - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { state } = useWorkspaceState(); const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + useThreadOutboxDrain(); const handleSettingsTransitionEnd = useCallback( (event: { data: { closing: boolean } }) => { @@ -81,7 +81,7 @@ function AppNavigatorContent() { sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; - if (isLoadingSavedConnection) { + if (state.isLoadingConnections) { return ; } @@ -129,8 +129,6 @@ export default function RootLayout() { DMSans_500Medium, DMSans_700Bold, }); - useRemoteEnvironmentBootstrap(); - return ( diff --git a/apps/mobile/src/app/connections/index.tsx b/apps/mobile/src/app/connections/index.tsx index 5db76f1c6b1..12a06996447 100644 --- a/apps/mobile/src/app/connections/index.tsx +++ b/apps/mobile/src/app/connections/index.tsx @@ -85,7 +85,7 @@ export default function ConnectionsRouteScreen() { type="monochrome" /> - + No environments connected yet.{"\n"}Tap{" "} + to add one. diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx index 566c038cc24..ca9693dbb19 100644 --- a/apps/mobile/src/app/connections/new.tsx +++ b/apps/mobile/src/app/connections/new.tsx @@ -1,5 +1,6 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; import { Alert, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -111,12 +112,12 @@ export default function ConnectionsNewRouteScreen() { const handleSubmit = useCallback(async () => { setIsSubmitting(true); - try { - const pairingUrl = buildPairingUrl(hostInput, codeInput); - onChangeConnectionPairingUrl(pairingUrl); - await onConnectPress(pairingUrl); + const pairingUrl = buildPairingUrl(hostInput, codeInput); + onChangeConnectionPairingUrl(pairingUrl); + const result = await onConnectPress(pairingUrl); + if (AsyncResult.isSuccess(result)) { dismissRoute(router); - } catch { + } else { setIsSubmitting(false); } }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); @@ -170,7 +171,7 @@ export default function ConnectionsNewRouteScreen() { className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" style={{ borderCurve: "continuous" }} > - + Camera permission is required to scan a QR code. Host @@ -201,13 +202,13 @@ export default function ConnectionsNewRouteScreen() { placeholderTextColor={placeholderColor} value={hostInput} onChangeText={handleHostChange} - className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> Pairing code @@ -219,7 +220,7 @@ export default function ConnectionsNewRouteScreen() { placeholderTextColor={placeholderColor} value={codeInput} onChangeText={handleCodeChange} - className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-base text-foreground" /> diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index c2b94dd9097..7f9962efc98 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,112 +1,119 @@ -import { Stack, useRouter } from "expo-router"; -import { useState } from "react"; -import { Text as RNText, View } from "react-native"; +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useRouter } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; +import { useProjects, useThreadShells } from "../state/entities"; +import { useWorkspaceState } from "../state/workspace"; import { buildThreadRoutePath } from "../lib/routes"; -import { useRemoteCatalog } from "../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; import { HomeScreen } from "../features/home/HomeScreen"; -import { useThemeColor } from "../lib/useThemeColor"; +import { HomeHeader } from "../features/home/HomeHeader"; +import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; +import { useThreadListActions } from "../features/home/useThreadListActions"; + +interface HomeListOptions { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; +} /* ─── Route screen ───────────────────────────────────────────────────── */ export default function HomeRouteScreen() { - const { projects, state: catalogState, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); - - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); + const [listOptions, setListOptions] = useState({ + selectedEnvironmentId: null, + projectSortOrder: + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" + ? "updated_at" + : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, + projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + }); + const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const environments = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput( + Order.String, + (environment: { readonly label: string }) => environment.label, + ), + ), + [savedConnectionsById], + ); + const selectedEnvironmentId = environments.some( + (environment) => environment.environmentId === listOptions.selectedEnvironmentId, + ) + ? listOptions.selectedEnvironmentId + : null; + const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => { + setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId })); + }, []); + const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => { + setListOptions((current) => ({ ...current, projectSortOrder })); + }, []); + const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => { + setListOptions((current) => ({ ...current, threadSortOrder })); + }, []); + const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => { + setListOptions((current) => ({ ...current, projectGroupingMode })); + }, []); return ( <> - { - setSearchQuery(event.nativeEvent.text); - }, - allowToolbarIntegration: true, - }, - }} + router.push("/settings")} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onStartNewTask={() => router.push("/new")} + onThreadSortOrderChange={setThreadSortOrder} /> - {/* Header left: plain text, no Liquid Glass button chrome */} - - - - - T3 Code - - - - Alpha - - - - - - - - router.push("/settings")} - separateBackground - /> - - - {/* Bottom toolbar: search + compose, visually split like iMessage */} - - - - router.push("/new")} - separateBackground - /> - - router.push("/connections/new")} + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onOpenEnvironments={() => router.push("/settings/environments")} onSelectThread={(thread) => { router.push(buildThreadRoutePath(thread)); }} + projectGroupingMode={listOptions.projectGroupingMode} + projects={projects} + projectSortOrder={listOptions.projectSortOrder} + savedConnectionsById={savedConnectionsById} + searchQuery={searchQuery} + selectedEnvironmentId={selectedEnvironmentId} + threads={threads} + threadSortOrder={listOptions.threadSortOrder} /> ); diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx index 2861dded1ad..7bf23a4955a 100644 --- a/apps/mobile/src/app/new/add-project/repository.tsx +++ b/apps/mobile/src/app/new/add-project/repository.tsx @@ -1,5 +1,5 @@ import { Stack, useLocalSearchParams } from "expo-router"; -import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime"; +import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx index 76102d842f4..6e2aa64ce11 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/app/new/index.tsx @@ -8,16 +8,17 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { useProjects, useThreadShells } from "../../state/entities"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; -import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; -function deriveProjectEmptyState(catalogState: RemoteCatalogState): { +function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; readonly detail: string; readonly loading: boolean; } { - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -25,7 +26,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment before creating a task.", @@ -33,7 +34,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -63,8 +69,9 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { } export default function NewTaskRoute() { - const { projects, state: catalogState, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); const router = useRouter(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); @@ -122,10 +129,10 @@ export default function NewTaskRoute() { {items.length === 0 ? ( {projectEmptyState.loading ? : null} - + {projectEmptyState.title} - + {projectEmptyState.detail} {!catalogState.hasReadyEnvironment ? ( @@ -133,7 +140,7 @@ export default function NewTaskRoute() { className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" onPress={() => router.push("/connections/new")} > - + Add environment @@ -142,7 +149,7 @@ export default function NewTaskRoute() { className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" onPress={() => router.push("/new/add-project")} > - + Add new project @@ -183,21 +190,14 @@ export default function NewTaskRoute() { - - {item.title} - + {item.title} { if (event.data.closing) { collapse(); @@ -47,9 +47,14 @@ export default function SettingsLayout() { name="waitlist" options={{ animation: "slide_from_right", title: "Join the waitlist" }} /> + diff --git a/apps/mobile/src/app/settings/archive.tsx b/apps/mobile/src/app/settings/archive.tsx new file mode 100644 index 00000000000..2b900afbbce --- /dev/null +++ b/apps/mobile/src/app/settings/archive.tsx @@ -0,0 +1,3 @@ +import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; + +export default ArchivedThreadsRouteScreen; diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b..c09bb3cebe6 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); + }} + 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); + }} + className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" + > + + Copy trace ID + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index c8b4cd40995..41799ae7b8b 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -8,6 +8,13 @@ import type { ComponentProps, ReactNode } from "react"; import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + isAtomCommandInterrupted, + reportAtomCommandResult, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { AppText as Text } from "../../components/AppText"; import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; @@ -18,10 +25,10 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, } from "../../features/cloud/publicConfig"; -import { mobileRuntime } from "../../lib/runtime"; +import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -32,7 +39,7 @@ export default function SettingsRouteScreen() { function LocalSettingsRouteScreen() { const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const environmentCount = Object.keys(savedConnectionsById).length; return ( @@ -58,6 +65,8 @@ function LocalSettingsRouteScreen() { /> + + @@ -70,7 +79,7 @@ function ConfiguredSettingsRouteScreen() { const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); @@ -87,8 +96,13 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("unsupported"); return; } - const permission = await Notifications.getPermissionsAsync(); - setNotificationStatus(permission.granted ? "enabled" : "disabled"); + const result = await settlePromise(() => Notifications.getPermissionsAsync()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "notification permission refresh" }); + setNotificationStatus("disabled"); + return; + } + setNotificationStatus(result.value.granted ? "enabled" : "disabled"); }, []); useEffect(() => { @@ -104,60 +118,66 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("signed-out"); return; } - void loadPreferences().then( - (preferences) => { - setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled"); - }, - () => { + void (async () => { + const result = await settlePromise(() => loadPreferences()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); - }, - ); + return; + } + setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); + })(); }, [isLoaded, isSignedIn]); const requestNotifications = useCallback(async () => { - try { - const result = await mobileRuntime.runPromise( + const result = await settleAsyncResult(() => + runtime.runPromiseExit( requestAgentNotificationPermission.pipe( Effect.tap((permission) => permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, ), ), - ); - if (result.type === "granted") { - setNotificationStatus("enabled"); - Alert.alert( - "Notifications enabled", - "Live Activity notifications are enabled for this device.", - ); - return; - } - if (result.type === "unsupported") { - setNotificationStatus("unsupported"); + ), + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); Alert.alert( "Notifications unavailable", - "Live Activity notifications are only available on iOS.", + error instanceof Error ? error.message : "Could not request notification permission.", ); - return; - } - setNotificationStatus("disabled"); - if (result.canAskAgain) { - Alert.alert("Notifications disabled", "Notifications were not enabled."); - return; } + return; + } + if (result.value.type === "granted") { + setNotificationStatus("enabled"); Alert.alert( - "Notifications disabled", - "Notifications were denied for this app. Open Settings to enable them.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Open Settings", onPress: () => void Linking.openSettings() }, - ], + "Notifications enabled", + "Live Activity notifications are enabled for this device.", ); - } catch (error) { + return; + } + if (result.value.type === "unsupported") { + setNotificationStatus("unsupported"); Alert.alert( "Notifications unavailable", - error instanceof Error ? error.message : "Could not request notification permission.", + "Live Activity notifications are only available on iOS.", ); + return; + } + setNotificationStatus("disabled"); + if (result.value.canAskAgain) { + Alert.alert("Notifications disabled", "Notifications were not enabled."); + return; } + Alert.alert( + "Notifications disabled", + "Notifications were denied for this app. Open Settings to enable them.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); }, []); const promptSignIn = useCallback(() => { @@ -178,36 +198,51 @@ function ConfiguredSettingsRouteScreen() { } setLiveActivityStatus("linking"); - try { - const token = await getToken(resolveRelayClerkTokenOptions()); - if (!token) { - promptSignIn(); - setLiveActivityStatus("signed-out"); - return; - } + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(tokenResult); + Alert.alert( + "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable Live Activity updates.", + ); + return; + } + if (!tokenResult.value) { + promptSignIn(); + setLiveActivityStatus("signed-out"); + return; + } - await mobileRuntime.runPromise( + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, - clerkToken: token, + clerkToken: tokenResult.value, connections, }), - ); - refreshManagedRelayEnvironments(); - setLiveActivityStatus("enabled"); - Alert.alert( - "Live Activities enabled", - environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` - : "Live Activity updates are enabled. Add an environment to start receiving updates.", - ); - } catch (error) { + ), + ); + if (updateResult._tag === "Failure") { setLiveActivityStatus("disabled"); - Alert.alert( - "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable Live Activity updates.", - ); + if (!isAtomCommandInterrupted(updateResult)) { + const error = squashAtomCommandFailure(updateResult); + Alert.alert( + "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable Live Activity updates.", + ); + } + return; } + + refreshManagedRelayEnvironments(); + setLiveActivityStatus("enabled"); + Alert.alert( + "Live Activities enabled", + environmentCount > 0 + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` + : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ); }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); const handleDeviceNotificationsChange = useCallback( @@ -234,19 +269,36 @@ function ConfiguredSettingsRouteScreen() { if (!enabled) { setLiveActivityStatus("disabled"); void (async () => { - try { - const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; - await mobileRuntime.runPromise( + let token: string | null = null; + if (isSignedIn) { + const tokenResult = await settlePromise(() => + getToken(resolveRelayClerkTokenOptions()), + ); + if (tokenResult._tag === "Failure") { + reportAtomCommandResult(tokenResult, { + label: "live activity disable token lookup", + }); + return; + } + token = tokenResult.value; + } + + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: token, connections, }), - ); - refreshManagedRelayEnvironments(); - } catch { - // The switch is optimistic; a future refresh reconciles relay state. + ), + ); + if (updateResult._tag === "Failure") { + reportAtomCommandResult(updateResult, { + label: "live activity disable", + }); + return; } + refreshManagedRelayEnvironments(); })(); return; } @@ -294,7 +346,7 @@ function ConfiguredSettingsRouteScreen() { onPress={openAccount} /> - + T3 Code works locally without signing in. Cloud features are optional. @@ -324,6 +376,8 @@ function ConfiguredSettingsRouteScreen() { /> + + @@ -335,7 +389,7 @@ type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { return ( - {props.title} + {props.title} - Version - Alpha + Version + Alpha ); } +function ArchivedThreadsSettingsSection() { + return ( + + + + ); +} + function SettingsRow(props: { readonly disabled?: boolean; readonly icon: SymbolName; readonly label: string; readonly value?: string; - readonly href?: "/settings/environments"; + readonly href?: "/settings/archive" | "/settings/environments"; readonly onPress?: () => void; }) { const icon = useThemeColor("--color-icon"); @@ -382,15 +444,20 @@ function SettingsRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.label} - {props.value ? ( - - {props.value} - - ) : null} + + {props.label} + + + {props.value ? ( + + {props.value} + + ) : null} + - {content} + + {content} + ); } @@ -433,7 +502,7 @@ function SettingsSwitchRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.label} + {props.label} + + ; +} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx new file mode 100644 index 00000000000..b67630dbf06 --- /dev/null +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx @@ -0,0 +1,5 @@ +import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; + +export default function ThreadFilesIndexRoute() { + return ; +} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index a3587d643ec..d98a8573e6c 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -40,7 +40,7 @@ export function AppTextInput({ - + T3 Code {stageLabel} @@ -38,7 +35,7 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab {!compact ? ( - + Mobile control surface for your live coding environments ) : null} diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index 7cb93454f88..e054a13f697 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbarTrigger.tsx @@ -223,7 +223,7 @@ export function ComposerToolbarButton(props: { {props.label ? ( - - {props.actionLabel} - + {props.actionLabel} ) : null} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 3fb8ba5d917..d47f924b398 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -4,7 +4,7 @@ import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - + {props.message} diff --git a/apps/mobile/src/components/LoadingStrip.tsx b/apps/mobile/src/components/LoadingStrip.tsx new file mode 100644 index 00000000000..9c16e1c68e7 --- /dev/null +++ b/apps/mobile/src/components/LoadingStrip.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +const INDICATOR_WIDTH_FRACTION = 0.3; +const MIN_INDICATOR_WIDTH = 48; + +function LoadingStripFrame(props: { + readonly children: React.ReactNode; + readonly onLayout?: (width: number) => void; +}) { + return ( + { + props.onLayout?.(event.nativeEvent.layout.width); + } + : undefined + } + > + {props.children} + + ); +} + +function IndeterminateLoadingStrip() { + const [containerWidth, setContainerWidth] = useState(0); + const travelProgress = useSharedValue(0); + const indicatorWidth = Math.max(MIN_INDICATOR_WIDTH, containerWidth * INDICATOR_WIDTH_FRACTION); + + useEffect(() => { + travelProgress.value = 0; + travelProgress.value = withRepeat( + withTiming(1, { + duration: 1100, + easing: Easing.inOut(Easing.quad), + }), + -1, + false, + ); + + return () => { + cancelAnimation(travelProgress); + }; + }, [travelProgress]); + + const indicatorStyle = useAnimatedStyle( + () => ({ + transform: [ + { + translateX: (containerWidth + indicatorWidth) * travelProgress.value - indicatorWidth, + }, + ], + width: indicatorWidth, + }), + [containerWidth, indicatorWidth], + ); + + return ( + + + + ); +} + +export function LoadingStrip(props: { readonly progress?: number }) { + if (props.progress === undefined) { + return ; + } + + const clampedProgress = Math.min(1, Math.max(0, props.progress)); + + return ( + + + + ); +} diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 32297d8d9d2..ba306c5a9fe 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/StatusPill.tsx b/apps/mobile/src/components/StatusPill.tsx index 34e6f74b609..03985463aa8 100644 --- a/apps/mobile/src/components/StatusPill.tsx +++ b/apps/mobile/src/components/StatusPill.tsx @@ -26,7 +26,7 @@ export function StatusPill( diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts new file mode 100644 index 00000000000..b5bda400670 --- /dev/null +++ b/apps/mobile/src/connection/catalog-store.ts @@ -0,0 +1,122 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + EMPTY_CONNECTION_CATALOG_DOCUMENT, +} from "@t3tools/client-runtime/platform"; +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; +export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => catalogError("decode", cause), + }); + return yield* Effect.fromResult( + Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), + ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); +}); + +const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), + ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); + return JSON.stringify(encoded); +}); + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +export interface SecureCatalogStorage { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly deleteItem: (key: string) => Effect.Effect; +} + +export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( + storage: SecureCatalogStorage, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { + const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + const catalog = + legacyRaw === null || legacyRaw.trim() === "" + ? EMPTY_CONNECTION_CATALOG_DOCUMENT + : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( + Effect.mapError((cause) => catalogError("migrate", cause)), + Effect.catch((error) => + Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( + Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + ), + ), + ); + if (legacyRaw !== null && legacyRaw.trim() !== "") { + const encoded = yield* encodeCatalog(catalog); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + } + return catalog; + }); + + const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + let catalog: ConnectionCatalogDocumentType; + if (raw !== null && raw.trim() !== "") { + catalog = yield* decodeCatalog(raw).pipe( + Effect.catch((error) => + Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( + Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(loadLegacyCatalog()), + ), + ), + ); + } else { + catalog = yield* loadLegacyCatalog(); + } + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + const encoded = yield* encodeCatalog(next); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); diff --git a/apps/mobile/src/connection/catalog.ts b/apps/mobile/src/connection/catalog.ts new file mode 100644 index 00000000000..971fa891106 --- /dev/null +++ b/apps/mobile/src/connection/catalog.ts @@ -0,0 +1,5 @@ +import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; + +import { connectionAtomRuntime } from "./runtime"; + +export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/connection/migration.test.ts b/apps/mobile/src/connection/migration.test.ts new file mode 100644 index 00000000000..5cb17bd5bf7 --- /dev/null +++ b/apps/mobile/src/connection/migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +describe("migrateLegacyConnectionCatalog", () => { + it.effect("migrates bearer and relay-managed connections into the new catalog", () => + Effect.gen(function* () { + const bearerEnvironmentId = EnvironmentId.make("bearer-environment"); + const relayEnvironmentId = EnvironmentId.make("relay-environment"); + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: bearerEnvironmentId, + environmentLabel: "Local Mac", + pairingUrl: "https://local.example.test/pair", + displayUrl: "https://local.example.test", + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test", + bearerToken: "bearer-token", + authenticationMethod: "bearer", + }, + { + environmentId: relayEnvironmentId, + environmentLabel: "Cloud Mac", + pairingUrl: "https://relay.example.test", + displayUrl: "https://relay.example.test", + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + bearerToken: null, + authenticationMethod: "dpop", + relayManaged: true, + }, + ], + }), + ); + + expect(catalog.targets).toHaveLength(2); + expect( + catalog.targets.find((target) => target.environmentId === bearerEnvironmentId)?._tag, + ).toBe("BearerConnectionTarget"); + expect( + catalog.targets.find((target) => target.environmentId === relayEnvironmentId)?._tag, + ).toBe("RelayConnectionTarget"); + expect(catalog.profiles).toHaveLength(1); + expect(catalog.credentials).toHaveLength(1); + expect(catalog.credentials[0]?.credential).toMatchObject({ + _tag: "BearerConnectionCredential", + token: "bearer-token", + }); + }), + ); + + it.effect("drops invalid legacy bearer entries without credentials", () => + Effect.gen(function* () { + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: EnvironmentId.make("invalid-bearer"), + environmentLabel: "Invalid", + pairingUrl: "https://invalid.example.test/pair", + displayUrl: "https://invalid.example.test", + httpBaseUrl: "https://invalid.example.test", + wsBaseUrl: "wss://invalid.example.test", + bearerToken: null, + authenticationMethod: "bearer", + }, + ], + }), + ); + + expect(catalog.targets).toEqual([]); + }), + ); +}); diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts new file mode 100644 index 00000000000..6f324c9ff15 --- /dev/null +++ b/apps/mobile/src/connection/migration.ts @@ -0,0 +1,110 @@ +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + RelayConnectionTarget, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + type ConnectionCatalogDocument, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const LegacySavedRemoteConnection = Schema.Struct({ + environmentId: EnvironmentId, + environmentLabel: Schema.String, + pairingUrl: Schema.String, + displayUrl: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + bearerToken: Schema.NullOr(Schema.String), + authenticationMethod: Schema.optionalKey(Schema.Literals(["bearer", "dpop"])), + dpopAccessToken: Schema.optionalKey(Schema.String), + relayManaged: Schema.optionalKey(Schema.Literal(true)), +}); + +const LegacyConnectionDocument = Schema.Struct({ + connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)), +}); +const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument); + +export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass()( + "LegacyConnectionMigrationError", + { + message: Schema.String, + }, +) {} + +function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean { + return connection.relayManaged === true || connection.authenticationMethod === "dpop"; +} + +function migrateConnection( + document: ConnectionCatalogDocument, + connection: typeof LegacySavedRemoteConnection.Type, +): ConnectionCatalogDocument { + if (isRelayManaged(connection)) { + return registerConnectionInCatalog( + document, + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + }), + }), + ); + } + + if (connection.bearerToken === null || connection.bearerToken.trim() === "") { + return document; + } + + const connectionId = `bearer:${connection.environmentId}`; + return registerConnectionInCatalog( + document, + new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: connection.environmentId, + label: connection.environmentLabel, + httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: connection.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: connection.bearerToken, + }), + }), + ); +} + +export const migrateLegacyConnectionCatalog = Effect.fn( + "mobile.connectionMigration.migrateCatalog", +)(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + new LegacyConnectionMigrationError({ + message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, + }), + }); + const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( + Effect.mapError( + (cause) => + new LegacyConnectionMigrationError({ + message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`, + }), + ), + ); + + return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT); +}); diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts new file mode 100644 index 00000000000..60a660cb4b8 --- /dev/null +++ b/apps/mobile/src/connection/onboarding.ts @@ -0,0 +1,35 @@ +import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { connectionAtomRuntime } from "./runtime"; + +const onboardingScheduler = createAtomCommandScheduler(); + +export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:connect-pairing-url", + scheduler: onboardingScheduler, + concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, + execute: (pairingUrl: string) => + ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + ), +}); + +export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:update-bearer", + scheduler: onboardingScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly environmentId: EnvironmentId }) => input.environmentId, + }, + execute: (input: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + }) => ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))), +}); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts new file mode 100644 index 00000000000..769632a8fcb --- /dev/null +++ b/apps/mobile/src/connection/platform.ts @@ -0,0 +1,211 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + PrimaryEnvironmentAuth, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + Connectivity, + Wakeups, +} from "@t3tools/client-runtime/connection"; +import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Network from "expo-network"; +import { AppState } from "react-native"; + +import { authClientMetadata } from "../lib/authClientMetadata"; +import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import { appAtomRegistry } from "../state/atom-registry"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; +import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { connectionStorageLayer } from "./storage"; + +function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { + if (state.isConnected === false || state.isInternetReachable === false) { + return "offline"; + } + if (state.isConnected === true) { + return "online"; + } + return "unknown"; +} + +const connectivityLayer = Connectivity.layer({ + status: Effect.tryPromise({ + try: () => Network.getNetworkStateAsync(), + catch: () => undefined, + }).pipe( + Effect.match({ + onFailure: () => "unknown" as const, + onSuccess: networkStatus, + }), + ), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => + Network.addNetworkStateListener((state) => { + Queue.offerUnsafe(queue, networkStatus(state)); + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => + AppState.addEventListener("change", (state) => { + if (state === "active") { + Queue.offerUnsafe(queue, "application-active"); + } + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + ), +}); + +const capabilitiesLayer = Layer.succeedContext( + Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }), + ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the mobile device identity: ${String(cause)}`, + }), + }).pipe(Effect.map(Option.some)), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ), +); + +const platformConnectionSourceLayer = Layer.succeed( + PlatformConnectionSource, + PlatformConnectionSource.of({ + registrations: Stream.empty, + }), +); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.all( + [ + Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), + Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + cause, + }), + ), + ), + }), +); + +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, +); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts new file mode 100644 index 00000000000..3698a0a5fc7 --- /dev/null +++ b/apps/mobile/src/connection/runtime.ts @@ -0,0 +1,24 @@ +import { Connection } from "@t3tools/client-runtime/connection"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { runtimeContextLayer } from "../lib/runtime"; +import { connectionPlatformLayer } from "./platform"; + +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof connectionPlatformLayer; + +const connectionLayer = Connection.layer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts new file mode 100644 index 00000000000..031c152e659 --- /dev/null +++ b/apps/mobile/src/connection/storage.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + CONNECTION_CATALOG_KEY, + LEGACY_CONNECTIONS_KEY, + makeCatalogStore, + type SecureCatalogStorage, +} from "./catalog-store"; + +function makeStorage(initial: Readonly>) { + const values = new Map(Object.entries(initial)); + const deleted: Array = []; + const storage: SecureCatalogStorage = { + getItem: (key) => Effect.sync(() => values.get(key) ?? null), + setItem: (key, value) => + Effect.sync(() => { + values.set(key, value); + }), + deleteItem: (key) => + Effect.sync(() => { + deleted.push(key); + values.delete(key); + }), + }; + return { deleted, storage, values }; +} + +describe("mobile connection catalog storage", () => { + it.effect("recovers from a corrupt current catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [CONNECTION_CATALOG_KEY]: "{not-json", + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); + }), + ); + + it.effect("replaces and removes a corrupt legacy catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); + expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); + }), + ); + + it.effect("falls back to valid legacy data when the current catalog is corrupt", () => + Effect.gen(function* () { + const memory = makeStorage({ + [CONNECTION_CATALOG_KEY]: "{not-json", + [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ + connections: [ + { + environmentId: "legacy-environment", + environmentLabel: "Legacy", + pairingUrl: "https://legacy.example.test/pair", + displayUrl: "https://legacy.example.test", + httpBaseUrl: "https://legacy.example.test", + wsBaseUrl: "wss://legacy.example.test", + bearerToken: "legacy-token", + authenticationMethod: "bearer", + }, + ], + }), + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toHaveLength(1); + expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); + + yield* catalog.update((document) => document); + expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); + expect(memory.values.has(LEGACY_CONNECTIONS_KEY)).toBe(false); + }), + ); +}); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts new file mode 100644 index 00000000000..276ea3c5c08 --- /dev/null +++ b/apps/mobile/src/connection/storage.ts @@ -0,0 +1,432 @@ +import { + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeConnectionFromCatalog, + removeCatalogValue, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionTransientError, + CredentialStore, + ProfileStore, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationThread, + OrchestrationShellSnapshot, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; + +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; +const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); + +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); + +const LegacyStoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + snapshotReceivedAt: Schema.String, + snapshot: OrchestrationShellSnapshot, +}); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function shellPersistenceError( + operation: + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function threadSnapshotFileName(threadId: ThreadId): string { + return `${encodeURIComponent(threadId)}.json`; +} + +const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( + function* ( + environmentId: EnvironmentId, + operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", + ) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory( + Paths.document, + THREAD_SNAPSHOT_CACHE_DIRECTORY, + encodeURIComponent(environmentId), + ); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } + return directory; + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); + }, +); + +const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( + environmentId: EnvironmentId, + threadId: ThreadId, + operation: "load-thread" | "save-thread" | "remove-thread", +) { + const { File } = yield* Effect.promise(() => import("expo-file-system")); + return new File( + yield* threadSnapshotDirectory(environmentId, operation), + threadSnapshotFileName(threadId), + ); +}); + +function targetPersistenceError( + operation: "list-targets" | "register-connection" | "remove-connection", + error: ConnectionTransientError, +) { + return new ConnectionPersistenceError({ + operation, + message: error.message, + }); +} + +const secureCatalogStorage: SecureCatalogStorage = { + getItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => catalogError("load", cause), + }), + setItem: (key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => catalogError("save", cause), + }), + deleteItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => catalogError("delete", cause), + }), +}; + +function shellSnapshotFileName(environmentId: EnvironmentId): string { + return `${encodeURIComponent(environmentId)}.json`; +} + +const shellSnapshotFileInDirectory = Effect.fn( + "mobile.connectionStorage.shellSnapshotFileInDirectory", +)(function* ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", + directoryName: string, +) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, directoryName); + directory.create({ idempotent: true, intermediates: true }); + return new File(directory, shellSnapshotFileName(environmentId)); + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); +}); + +const shellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); + +const legacyShellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const catalog = yield* makeCatalogStore(secureCatalogStorage); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((error) => targetPersistenceError("list-targets", error)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + }); + const profileStore = ProfileStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((candidate) => candidate.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = CredentialStore.make({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, + ), + ), + ), + put: (connectionId, credential) => + catalog.update((document) => ({ + ...document, + credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { + connectionId, + credential, + }), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + credentials: removeCatalogValue( + document.credentials, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const remoteTokenStore = TokenStore.make({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "load-shell"); + if (file.exists) { + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(); + } + + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); + if (!legacyFile.exists) { + return Option.none(); + } + const legacyRaw = yield* Effect.tryPromise({ + try: () => legacyFile.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyParsed = yield* Effect.try({ + try: () => JSON.parse(legacyRaw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyStored = yield* Effect.fromResult( + Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return legacyStored.environmentId === environmentId + ? Option.some(legacyStored.snapshot) + : Option.none(); + }), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "save-shell"); + const stored = { + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + } as const; + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredShellSnapshot)(stored), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-shell", cause), + }); + }), + loadThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); + if (!file.exists) { + return Option.none(); + } + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + return stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(); + }), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredThreadSnapshot)({ + schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + threadId: thread.id, + thread, + }), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-thread", cause), + }); + }), + removeThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); + if (file.exists) { + file.delete(); + } + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : shellPersistenceError("remove-thread", cause), + ), + ), + clear: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "clear-environment"); + if (file.exists) { + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); + if (legacyFile.exists) { + yield* Effect.try({ + try: () => legacyFile.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const threadDirectory = yield* threadSnapshotDirectory( + environmentId, + "clear-environment", + ); + if (threadDirectory.exists) { + yield* Effect.try({ + try: () => threadDirectory.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + }), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index f06868ed7d9..5de14ea76fc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = { bearerToken: "local-bearer", }; -const runWithHttpClient = ( - effect: Effect.Effect, -): Promise => - Effect.runPromise( - effect.pipe( - Effect.provideService(ManagedRelayClient, null as never), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - ), - ); +const testLayer = Layer.mergeAll( + Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), +); describe("liveActivityPreferences", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("pushes disabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + it.effect("pushes disabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("pushes enabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("pushes enabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("keeps local preferences refreshable when signed out", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps local preferences refreshable when signed out", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: null, connections: [connection], - }), - ); + }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); - }); + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer)), + ); - it("does not try to re-link managed relay connections without bearer credentials", async () => { + it.effect("does not try to re-link managed relay connections without bearer credentials", () => { const managedConnection: SavedRemoteConnection = { ...connection, bearerToken: null, }; - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + return Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection, managedConnection], - }), - ); - - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); + }); + + expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 7bf29483f1d..932376e8bce 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,21 +1,34 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( + "LiveActivityPreferenceSaveError", + { + enabled: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; + } +} + export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; readonly connections: ReadonlyArray; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { yield* Effect.tryPromise({ try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (error) => error, + catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), }); yield* refreshAgentAwarenessRegistration(); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index ce8dfddf3d2..dc275774a50 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -1,5 +1,6 @@ import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; export type NotificationPermissionResult = @@ -7,9 +8,31 @@ export type NotificationPermissionResult = | { readonly type: "granted" } | { readonly type: "denied"; readonly canAskAgain: boolean }; +export class NotificationPermissionReadError extends Schema.TaggedErrorClass()( + "NotificationPermissionReadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to read notification permissions on iOS."; + } +} + +export class NotificationPermissionRequestError extends Schema.TaggedErrorClass()( + "NotificationPermissionRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to request notification permissions on iOS."; + } +} + export const requestAgentNotificationPermission: Effect.Effect< NotificationPermissionResult, - unknown + NotificationPermissionReadError | NotificationPermissionRequestError > = Effect.gen(function* () { if (Platform.OS !== "ios") { return { type: "unsupported" }; @@ -17,7 +40,7 @@ export const requestAgentNotificationPermission: Effect.Effect< const existing = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => new NotificationPermissionReadError({ cause }), }); if (existing.granted) { return { type: "granted" }; @@ -36,7 +59,7 @@ export const requestAgentNotificationPermission: Effect.Effect< allowSound: true, }, }), - catch: (error) => error, + catch: (cause) => new NotificationPermissionRequestError({ cause }), }); return requested.granted ? { type: "granted" } diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 44ef38df0ef..a4e6fc3d6db 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,6 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { MobilePreferences } from "../../lib/storage"; +import type { Preferences } from "../../lib/storage"; export function makeRelayDeviceRegistrationRequest(input: { readonly deviceId: string; @@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: { readonly pushToken?: string; readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; - readonly preferences: MobilePreferences; + readonly preferences: Preferences; }): RelayDeviceRegistrationRequest { const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; return { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..43d62b81622 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -6,15 +6,16 @@ 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 { __resetAgentAwarenessRemoteRegistrationForTest, @@ -33,6 +34,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 +102,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 +139,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 +250,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 +258,64 @@ 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()); - const activity = { - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - }; + 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 expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); - }); + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + }).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( + "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")); - await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration()); + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); - expect(activity.getPushToken).toHaveBeenCalled(); - expect(activity.start).not.toHaveBeenCalled(); - expect(activity.update).not.toHaveBeenCalled(); - expect(activity.end).not.toHaveBeenCalled(); - }); + expect(activity.getPushToken).toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + expect(activity.update).not.toHaveBeenCalled(); + expect(activity.end).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); - it("refreshes APNs registration for connected environments after settings changes", async () => { + 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 +345,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 +372,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 +440,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 +471,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 +506,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { registerAgentAwarenessConnection(savedConnection()); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + yield* runBackgroundOperations(); fetchMock.mockClear(); unregisterAgentAwarenessConnection(savedConnection().environmentId); expect(fetchMock).not.toHaveBeenCalled(); - }); + }).pipe(Effect.provide(relayTestLayer)); }, ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3e49ec1e257..98e38c74055 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -8,10 +8,16 @@ 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, @@ -29,6 +35,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 +88,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 +115,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 { @@ -149,27 +174,48 @@ const relayToken = Effect.gen(function* () { 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; + 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({ @@ -181,7 +227,7 @@ function unregisterDeviceWithRelay(input: { return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.unregisterDevice({ clerkToken: token, deviceId: input.deviceId, @@ -191,7 +237,7 @@ function unregisterDeviceWithRelay(input: { function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; const token = yield* relayToken; @@ -200,7 +246,7 @@ function registerLiveActivityWithRelay( return false; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.registerLiveActivity({ clerkToken: token, payload: body, @@ -213,10 +259,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,23 +274,110 @@ 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(), @@ -255,6 +389,10 @@ function registerDevice(input?: { }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + logRegistrationDebug("device registration local state ready", { + expectedGeneration, + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + }); yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, @@ -266,21 +404,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 +439,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 +455,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 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void { export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return registerDeviceForCurrentUser().pipe( Effect.catch((error) => @@ -372,11 +508,14 @@ 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(), @@ -397,7 +536,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( export function registerLiveActivityPushToken(input: { readonly activity: LiveActivity; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { return false; @@ -449,7 +588,7 @@ 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(), @@ -485,7 +624,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx new file mode 100644 index 00000000000..d560f8db9fa --- /dev/null +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -0,0 +1,95 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useFocusEffect } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; + +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; +import { useArchivedThreadListActions } from "../home/useThreadListActions"; +import { + ArchivedThreadsScreen, + type ArchivedThreadsHeaderEnvironment, +} from "./ArchivedThreadsScreen"; +import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList"; +import { + refreshArchivedThreadsForEnvironment, + useArchivedThreadSnapshots, +} from "./useArchivedThreadSnapshots"; + +export function ArchivedThreadsRouteScreen() { + const { expand } = useClerkSettingsSheetDetent(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const [sortOrder, setSortOrder] = useState("newest"); + const environments = useMemo>( + () => + Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + })), + Order.mapInput(Order.String, (environment: ArchivedThreadsHeaderEnvironment) => + environment.label.toLocaleLowerCase(), + ), + ), + [savedConnectionsById], + ); + const environmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const environmentLabels = useMemo( + () => + Object.fromEntries( + environments.map((environment) => [environment.environmentId, environment.label]), + ), + [environments], + ); + const { error, isLoading, refresh, snapshots } = useArchivedThreadSnapshots(environmentIds); + const groups = useMemo( + () => + buildArchivedThreadGroups({ + snapshots, + environmentLabels, + environmentId: selectedEnvironmentId, + searchQuery, + sortOrder, + }), + [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder], + ); + const refreshChangedEnvironment = useCallback( + (thread: { readonly environmentId: EnvironmentId }) => { + refreshArchivedThreadsForEnvironment(thread.environmentId); + }, + [], + ); + const { unarchiveThread, confirmDeleteThread } = + useArchivedThreadListActions(refreshChangedEnvironment); + + useFocusEffect( + useCallback(() => { + expand(); + refresh(); + }, [expand, refresh]), + ); + + return ( + + ); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx new file mode 100644 index 00000000000..3e1934100cd --- /dev/null +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -0,0 +1,436 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; +import * as Haptics from "expo-haptics"; +import { Stack } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useRef } from "react"; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + THREAD_SWIPE_ACTIONS_WIDTH, + THREAD_SWIPE_SPRING, + ThreadSwipeActions, +} from "../home/thread-swipe-actions"; +import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; + +export interface ArchivedThreadsHeaderEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +const THREAD_ACTIONS: MenuAction[] = [ + { + id: "unarchive", + title: "Unarchive", + image: "arrow.uturn.backward", + }, + { + id: "delete", + title: "Delete", + image: "trash", + attributes: { destructive: true }, + }, +]; + +function ArchivedThreadsHeader(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly sortOrder: ArchivedThreadSortOrder; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onSearchQueryChange: (query: string) => void; + readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; +}) { + const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + + return ( + <> + { + props.onSearchQueryChange(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + }, + }} + /> + + + + + Environment + props.onEnvironmentChange(null)} + > + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + + + + Sort by archived date + props.onSortOrderChange("newest")} + > + Newest first + + props.onSortOrderChange("oldest")} + > + Oldest first + + + + + + ); +} + +function ProjectGroupLabel(props: { + readonly environmentLabel: string | null; + readonly project: EnvironmentProject; +}) { + return ( + + + + {props.project.title} + + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ); +} + +function ArchivedThreadRow(props: { + readonly environmentLabel: string | null; + readonly isLast: boolean; + readonly onDelete: () => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onUnarchive: () => void; + readonly thread: EnvironmentThreadShell; +}) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const { width: windowWidth } = useWindowDimensions(); + const cardColor = useThemeColor("--color-card"); + const iconColor = useThemeColor("--color-icon-subtle"); + const separatorColor = useThemeColor("--color-separator"); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); + const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); + const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => + Boolean(part), + ); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); + const handleMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + if (event.nativeEvent.event === "unarchive") { + props.onUnarchive(); + } else if (event.nativeEvent.event === "delete") { + props.onDelete(); + } + }, + [props.onDelete, props.onUnarchive], + ); + + return ( + { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) return; + + props.onSwipeableWillOpen(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + > + + + + + + + + + {props.thread.title} + + + {timestamp} + + + {subtitle.length > 0 ? ( + + + + {subtitle.join(" · ")} + + + ) : null} + + + + + + + + + + ); +} + +function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) { + return ( + + + Could not load every archive + + {props.message} + + Try again + + + ); +} + +export function ArchivedThreadsScreen(props: { + readonly environments: ReadonlyArray; + readonly error: string | null; + readonly groups: ReadonlyArray; + readonly isLoading: boolean; + readonly searchQuery: string; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly sortOrder: ArchivedThreadSortOrder; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onRefresh: () => void; + readonly onSearchQueryChange: (query: string) => void; + readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; + readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; +}) { + const openSwipeableRef = useRef(null); + const refreshTint = useThemeColor("--color-icon"); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current && openSwipeableRef.current !== methods) { + openSwipeableRef.current.close(); + } + openSwipeableRef.current = methods; + }, []); + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; + } + }, []); + const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; + const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; + + return ( + + + + openSwipeableRef.current?.close()} + refreshControl={ + + } + showsVerticalScrollIndicator={false} + > + {props.error ? : null} + + {isInitialLoad ? ( + + + Loading archive… + + ) : props.groups.length === 0 ? ( + + ) : ( + props.groups.map((group) => { + const environmentLabel = + props.environments.find( + (environment) => environment.environmentId === group.project.environmentId, + )?.label ?? null; + + return ( + + + + {group.threads.map((thread, index) => ( + props.onDeleteThread(thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + onUnarchive={() => props.onUnarchiveThread(thread)} + thread={thread} + /> + ))} + + + ); + }) + )} + + + ); +} diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts new file mode 100644 index 00000000000..6cd530ab37d --- /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 00000000000..6146bba2044 --- /dev/null +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -0,0 +1,106 @@ +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import { + scopeProject, + scopeThreadShell, + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +export type ArchivedThreadSortOrder = "newest" | "oldest"; + +export interface ArchivedThreadGroup { + readonly key: string; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; +} + +function archiveTimestamp(thread: EnvironmentThreadShell): number { + const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function matchesQuery(value: string | null, query: string): boolean { + return value?.toLocaleLowerCase().includes(query) ?? false; +} + +export function buildArchivedThreadGroups(input: { + readonly snapshots: ReadonlyArray; + readonly environmentLabels: Readonly>; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; + readonly sortOrder: ArchivedThreadSortOrder; +}): ReadonlyArray { + const query = input.searchQuery.trim().toLocaleLowerCase(); + const groups: ArchivedThreadGroup[] = []; + + for (const entry of input.snapshots) { + if (input.environmentId !== null && input.environmentId !== entry.environmentId) { + continue; + } + + const environmentLabel = input.environmentLabels[entry.environmentId] ?? null; + const threadsByProjectId = new Map(); + for (const thread of entry.snapshot.threads) { + if (thread.archivedAt === null) { + continue; + } + const threads = threadsByProjectId.get(thread.projectId) ?? []; + threads.push(scopeThreadShell(entry.environmentId, thread)); + threadsByProjectId.set(thread.projectId, threads); + } + + for (const rawProject of entry.snapshot.projects) { + const project = scopeProject(entry.environmentId, rawProject); + const projectThreads = threadsByProjectId.get(project.id) ?? []; + const groupMatches = + query.length === 0 || + matchesQuery(project.title, query) || + matchesQuery(project.workspaceRoot, query) || + matchesQuery(environmentLabel, query); + const matchingThreads = groupMatches + ? projectThreads + : projectThreads.filter( + (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query), + ); + + if (matchingThreads.length === 0) { + continue; + } + + const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + groups.push({ + key: scopedProjectKey(project.environmentId, project.id), + project, + threads: Arr.sort( + matchingThreads, + Order.mapInput( + Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }), + (thread: EnvironmentThreadShell) => ({ + timestamp: archiveTimestamp(thread), + title: thread.title, + id: thread.id, + }), + ), + ), + }); + } + } + + const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + return Arr.sort( + groups, + Order.mapInput( + Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), + (group: ArchivedThreadGroup) => ({ + timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, + title: group.project.title, + key: group.key, + }), + ), + ); +} diff --git a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts new file mode 100644 index 00000000000..d18cc230c63 --- /dev/null +++ b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts @@ -0,0 +1,47 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type ArchivedSnapshotEntry, + createArchivedThreadSnapshotsAtomFamily, + makeArchivedThreadsEnvironmentKey, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { orchestrationEnvironment } from "../../state/orchestration"; + +function archivedSnapshotAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }); +} + +const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: archivedSnapshotAtom, + labelPrefix: "mobile:archived-thread-snapshots", +}); + +export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); +} + +export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { + readonly snapshots: ReadonlyArray; + readonly error: string | null; + readonly isLoading: boolean; + readonly refresh: () => void; +} { + const environmentKey = useMemo( + () => makeArchivedThreadsEnvironmentKey(environmentIds), + [environmentIds], + ); + const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); + const refresh = useCallback(() => { + for (const environmentId of environmentIds) { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); + } + }, [environmentIds]); + + return { ...result, refresh }; +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts new file mode 100644 index 00000000000..2bc62d2a34e --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -0,0 +1,60 @@ +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { activateCloudRelayAccount, deactivateCloudRelayAccount } from "./CloudAuthProvider"; +import { setAgentAwarenessRelayTokenProvider } from "../agent-awareness/remoteRegistration"; + +vi.mock("@clerk/expo", () => ({ + ClerkProvider: vi.fn(), + useAuth: vi.fn(), +})); + +vi.mock("@clerk/expo/token-cache", () => ({ + tokenCache: {}, +})); + +vi.mock("../../lib/runtime", () => ({ + runtime: { + runPromiseExit: vi.fn(), + }, +})); + +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { + removeRelayEnvironments: {}, + }, +})); + +vi.mock("./publicConfig", () => ({ + resolveCloudPublicConfig: vi.fn(() => ({ + clerk: { publishableKey: null }, + relay: { url: null }, + })), + resolveRelayClerkTokenOptions: vi.fn(), +})); + +vi.mock("../agent-awareness/remoteRegistration", () => ({ + setAgentAwarenessRelayTokenProvider: vi.fn(), + unregisterAgentAwarenessDeviceForCurrentUser: vi.fn(), +})); + +afterEach(() => { + deactivateCloudRelayAccount(); + vi.clearAllMocks(); +}); + +describe("CloudAuthProvider relay account isolation", () => { + it("clears relay and agent-awareness credentials before cleanup can fail", async () => { + const tokenProvider = async () => "account-1-token"; + activateCloudRelayAccount("account-1", tokenProvider); + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); + + deactivateCloudRelayAccount(); + const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); + + expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); + expect(vi.mocked(setAgentAwarenessRelayTokenProvider)).toHaveBeenLastCalledWith(null); + await cleanup; + }); +}); diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 5fc3b96fdc8..c89aeb9249a 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,63 +1,149 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { + reportAtomCommandResult, + settleAsyncResult, + settlePromise, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { mobileRuntime } from "../../lib/runtime"; +import { environmentCatalog } from "../../connection/catalog"; +import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { useAtomCommand } from "../../state/use-atom-command"; import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +function resetManagedRelayTokenCache() { + return settleAsyncResult(() => + runtime.runPromiseExit( + ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ), + ); +} + +export function deactivateCloudRelayAccount(): void { + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); +} + +export function activateCloudRelayAccount( + accountId: string, + tokenProvider: () => Promise, +): void { + setAgentAwarenessRelayTokenProvider(tokenProvider, accountId); + setManagedRelaySession(appAtomRegistry, { + accountId, + readClerkToken: tokenProvider, + }); +} + function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + reportFailure: false, + reportDefect: false, + }); const previousTokenProviderRef = useRef<{ readonly userId: string; readonly provider: () => Promise; } | null>(null); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef | null>(null); useEffect(() => { + let cancelled = false; if (!isLoaded) { return; } + + const previousObservedAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = ( + previous: { + readonly userId: string; + readonly provider: () => Promise; + } | null, + ) => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition.then(async () => { + const cleanup = [ + resetManagedRelayTokenCache(), + removeRelayEnvironments(), + ...(previous + ? [ + settleAsyncResult(() => + runtime.runPromiseExit( + unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), + ), + ), + ] + : []), + ]; + const results = await Promise.all(cleanup); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }); + return accountTransitionRef.current; + }; + if (!isSignedIn || !userId) { const previous = previousTokenProviderRef.current; previousTokenProviderRef.current = null; - if (previous) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); + deactivateCloudRelayAccount(); + if (previousObservedAccount !== null) { + void queueAccountCleanup(previous); } - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); return; } const previous = previousTokenProviderRef.current; - if (previous && previous.userId !== userId) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); - } const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); - previousTokenProviderRef.current = { userId, provider: tokenProvider }; - setAgentAwarenessRelayTokenProvider(tokenProvider, userId); - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: userId, - readClerkToken: tokenProvider, - }), - ); - }, [getToken, isLoaded, isSignedIn, userId]); + const activateSession = () => { + if (cancelled) { + return; + } + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + activateCloudRelayAccount(userId, tokenProvider); + }; + const activateAfterTransition = (transition: Promise) => { + void (async () => { + const result = await settlePromise(async () => { + await transition; + activateSession(); + }); + reportAtomCommandResult(result, { label: "cloud account activation" }); + })(); + }; + if ( + previousObservedAccount !== undefined && + previousObservedAccount !== null && + previousObservedAccount !== userId + ) { + previousTokenProviderRef.current = null; + deactivateCloudRelayAccount(); + activateAfterTransition(queueAccountCleanup(previous)); + } else { + activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + } + + return () => { + cancelled = true; + }; + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); useEffect( () => () => { previousTokenProviderRef.current = null; - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); + deactivateCloudRelayAccount(); }, [], ); @@ -72,8 +158,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { useEffect(() => { if (!publishableKey || !relayUrl) { - setAgentAwarenessRelayTokenProvider(null); - setManagedRelaySession(appAtomRegistry, null); + deactivateCloudRelayAccount(); } }, [publishableKey, relayUrl]); diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index 1528a8fb97f..77b48d44b1e 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -2,6 +2,7 @@ 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"; export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { @@ -141,12 +142,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 +156,7 @@ const styles = StyleSheet.create({ }, error: { fontFamily: "DMSans_400Regular", - fontSize: 13, - lineHeight: 18, + ...MOBILE_TYPOGRAPHY.footnote, }, field: { gap: 8, @@ -167,15 +166,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 +194,11 @@ const styles = StyleSheet.create({ }, signInText: { fontFamily: "DMSans_700Bold", - fontSize: 15, - lineHeight: 21, + ...MOBILE_TYPOGRAPHY.body, }, title: { fontFamily: "DMSans_700Bold", - fontSize: 20, - lineHeight: 26, + ...MOBILE_TYPOGRAPHY.title, textAlign: "center", }, }); diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts new file mode 100644 index 00000000000..840a3db5568 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -0,0 +1,18 @@ +export function isCloudDebugEnabled(): boolean { + return ( + (typeof __DEV__ !== "undefined" && __DEV__) || + (typeof globalThis !== "undefined" && + (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) + ); +} + +export function cloudDebugLog(event: string, data?: Record): void { + if (!isCloudDebugEnabled()) { + return; + } + if (data) { + console.log(`[t3-cloud] ${event}`, data); + } else { + console.log(`[t3-cloud] ${event}`); + } +} diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts new file mode 100644 index 00000000000..05a34cc9835 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -0,0 +1,88 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; + +function relayStatus( + status: RelayEnvironmentStatusResponse["status"], + error?: string, + traceId?: string, +): RelayEnvironmentStatusResponse { + return { + environmentId: EnvironmentId.make("environment-cloud"), + endpoint: { + httpBaseUrl: "https://cloud.example.test/", + wsBaseUrl: "wss://cloud.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status, + checkedAt: "2026-06-05T16:49:11.000Z", + ...(error ? { error } : {}), + ...(traceId ? { traceId } : {}), + }; +} + +describe("available cloud environment presentation", () => { + it("presents an online unsaved environment as available, not connected", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("online"), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }); + }); + + it("keeps relay status checks distinct from connection attempts", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: true, + status: null, + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Checking relay status...", + }); + }); + + it("surfaces an offline relay as an error", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("offline", "Tunnel is unavailable.", "trace-offline"), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: "Tunnel is unavailable.", + connectionErrorTraceId: "trace-offline", + connectionState: "error", + statusText: "Tunnel is unavailable.", + }); + }); + + it("preserves trace metadata for relay request failures", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: null, + statusError: "Could not get relay environment status.", + statusErrorTraceId: "trace-status", + }), + ).toMatchObject({ + connectionError: "Could not get relay environment status.", + connectionErrorTraceId: "trace-status", + }); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts new file mode 100644 index 00000000000..8a734c9b935 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -0,0 +1,53 @@ +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export interface AvailableCloudEnvironmentPresentation { + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly statusText: string; +} + +export function availableCloudEnvironmentPresentation(input: { + readonly isStatusPending: boolean; + readonly status: RelayEnvironmentStatusResponse | null; + readonly statusError: string | null; + readonly statusErrorTraceId: string | null; +}): AvailableCloudEnvironmentPresentation { + if (input.status?.status === "online") { + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }; + } + + if (input.status?.status === "offline") { + const connectionError = input.status.error ?? "Relay is offline."; + return { + connectionError, + connectionErrorTraceId: input.status.traceId ?? null, + connectionState: "error", + statusText: connectionError, + }; + } + + if (input.statusError) { + return { + connectionError: input.statusError, + connectionErrorTraceId: input.statusErrorTraceId, + connectionState: "error", + statusText: input.statusError, + }; + } + + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: input.isStatusPending + ? "Available · Checking relay status..." + : "Available · Relay status unknown", + }; +} diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8eda21b96ce..8945d148ee9 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -12,7 +12,7 @@ import { createDpopProof, generateDpopProofKeyPair, loadOrCreateDpopProofKeyPair, - mobileCryptoLayer, + cryptoLayer, } from "./dpop"; vi.mock("expo-crypto", () => ({ @@ -75,7 +75,7 @@ describe("mobile DPoP", () => { expect(Buffer.from(digest).toString("hex")).toBe( NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), ); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("persists and reuses the installation proof key", () => @@ -86,7 +86,7 @@ describe("mobile DPoP", () => { expect(second.thumbprint).toBe(first.thumbprint); expect(second.privateJwk).toEqual(first.privateJwk); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("rejects malformed persisted proof keys", () => @@ -96,7 +96,7 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); expect(error.message).toBe("Stored DPoP proof key is invalid."); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => @@ -135,7 +135,7 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(bootstrap.proof), }), ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => @@ -161,6 +161,6 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(proof.proof), }), ).toMatchObject({ ok: true }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0a3d7c2a5a7..0bd4b7ff1bd 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -70,7 +70,7 @@ function toExpoDigestAlgorithm( } } -export const mobileCryptoLayer = Layer.succeed( +export const cryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({ randomBytes: ExpoCrypto.getRandomBytes, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 36544cf46cc..b9ab3aeab05 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,12 +4,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { @@ -55,13 +51,15 @@ const savedConnection = { bearerToken: "local-bearer", }; +const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test"; + const createProofMock = vi.fn( (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => Effect.succeed(`dpop:${input.method}:${input.url}`), ); const testDpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("client-proof-key-thumbprint"), createProof: (input) => createProofMock(input), }), @@ -71,7 +69,7 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, - managedRelayClientLayer({ + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), @@ -79,7 +77,11 @@ function cloudClientLayer() { } const withCloudServices = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + >, ) => effect.pipe(Effect.provide(cloudClientLayer())); function validLinkProof() { @@ -352,7 +354,7 @@ describe("mobile cloud link environment client", () => { }); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); + yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken })); expect( fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), @@ -425,9 +427,11 @@ describe("mobile cloud link environment client", () => { yield* withCloudServices( Effect.gen(function* () { - const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); + const records = yield* listCloudEnvironmentsWithStatus({ + clerkToken: stableClerkToken, + }); yield* connectCloudEnvironment({ - clerkToken: "clerk-token", + clerkToken: stableClerkToken, environment: records[0]!.environment, }); }), @@ -658,6 +662,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + traceId: "trace-test", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), @@ -1003,6 +1008,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + traceId: "trace-connect", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index bca1ac21bc7..a77ca628978 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -16,22 +16,19 @@ import { type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, - RelayProtectedError, type RelayDpopAccessTokenScope, type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, - ManagedRelayClient, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime"; - -import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; +import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; + +import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -56,6 +53,7 @@ function readRelayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} export interface CloudEnvironmentRecordWithStatus { @@ -64,7 +62,6 @@ export interface CloudEnvironmentRecordWithStatus { readonly statusError: string | null; } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -82,11 +79,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND = function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); + const traceId = findErrorTraceId(cause); return new CloudEnvironmentLinkError({ message: environmentError ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` : withDevCause(message, cause), cause, + ...(traceId === null ? {} : { traceId }), }); }; } @@ -148,31 +147,24 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(traceId ? { traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -267,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: { export function linkEnvironmentToCloud(input: { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -276,7 +272,7 @@ export function linkEnvironmentToCloud(input: { } const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), @@ -359,11 +355,11 @@ export function listCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ @@ -380,11 +376,11 @@ export function getCloudEnvironmentStatus(input: { }): Effect.Effect< RelayEnvironmentStatusResponseType, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const status = yield* relayClient .getEnvironmentStatus({ clerkToken: input.clerkToken, @@ -419,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.forEach( input.environments, @@ -451,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const environments = yield* listCloudEnvironments(input); @@ -462,23 +458,26 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -function connectRelayManagedEnvironment(input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly expectedEnvironment?: RelayClientEnvironmentRecord; -}): Effect.Effect< - SavedRemoteConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { - return Effect.gen(function* () { - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; - - const deviceId = yield* Effect.tryPromise({ +const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( + function* () { + return yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), }); + }, +); + +const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( + function* (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly expectedEnvironment?: RelayClientEnvironmentRecord; + }) { + yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + + const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -517,7 +516,7 @@ function connectRelayManagedEnvironment(input: { message: "Connected endpoint descriptor does not match the selected environment.", }); } - const signer = yield* ManagedRelayDpopSigner; + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; const bootstrapDpop = yield* signer .createProof({ method: "POST", @@ -528,7 +527,7 @@ function connectRelayManagedEnvironment(input: { httpBaseUrl: connect.endpoint.httpBaseUrl, credential: connect.credential, dpopProof: bootstrapDpop, - clientMetadata: mobileAuthClientMetadata(), + clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), @@ -548,9 +547,9 @@ function connectRelayManagedEnvironment(input: { authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, relayManaged: true, - }; - }); -} + } satisfies SavedRemoteConnection; + }, +); export function connectCloudEnvironment(input: { readonly clerkToken: string; @@ -558,7 +557,7 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -573,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 0de43d049c5..2da1fa9157c 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,42 +1,62 @@ -import { - managedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; +import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; -const mobileRelayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, +const relayDpopSignerLayer = Layer.effect( + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - return ManagedRelayDpopSigner.of({ - thumbprint: Effect.suspend(() => - loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + const loadProofKey = yield* Effect.cached( + loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); + return ManagedRelay.ManagedRelayDpopSigner.of({ + thumbprint: loadProofKey.pipe( + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "expo-secure-store", + cause: error, + }), ), + Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - Effect.gen(function* () { - const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - ); - return yield* createDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), + createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadProofKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); -export const mobileManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( - Layer.provideMerge(mobileRelayDpopSignerLayer), - ); +export const managedRelayClientLayer = (relayUrl: string) => + ManagedRelay.layer({ + relayUrl, + clientId: RelayMobileClientId, + accessTokenStore: managedRelayAccessTokenStore, + }).pipe(Layer.provideMerge(relayDpopSignerLayer)); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3394a519fd6..eec1e3410e6 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -3,20 +3,24 @@ import { createManagedRelayQueryManager, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientEnvironmentRecord, RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { mobileRuntimeContextLayer } from "../../lib/runtime"; +import { runtimeContextLayer } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { cloudDebugLog } from "./cloudDebugLog"; -const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); +const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, { + onQueryEvent: (event) => + cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), +}); const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), @@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) : EMPTY_ENVIRONMENT_STATUS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment status failed", { + environmentId: environment.environmentId, + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { @@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron }, [accountId, environment]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts new file mode 100644 index 00000000000..616fc1add7c --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +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 { 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([]); + }), +); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts new file mode 100644 index 00000000000..460c71c1fa7 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -0,0 +1,106 @@ +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Data from "effect/Data"; +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 Data.TaggedError("ManagedRelayTokenStoreError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const storeError = + (message: string) => + (cause: unknown): ManagedRelayTokenStoreError => + new ManagedRelayTokenStoreError({ message, cause }); + +function logStoreFailure(operation: string) { + return (error: ManagedRelayTokenStoreError) => + Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + message: error.message, + }), + ); +} + +const loadManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: storeError("Could not read persisted relay access tokens."), +}).pipe( + Effect.flatMap((encoded) => + encoded === null + ? Effect.succeed>([]) + : decodeManagedRelayAccessTokenCache(encoded).pipe( + Effect.map((cache) => cache.entries), + Effect.mapError(storeError("Persisted relay access tokens are invalid.")), + ), + ), +); + +const saveManagedRelayAccessTokens = ( + entries: ReadonlyArray, +) => + encodeManagedRelayAccessTokenCache({ + version: MANAGED_RELAY_TOKEN_CACHE_VERSION, + entries, + }).pipe( + Effect.mapError(storeError("Could not encode relay access tokens.")), + Effect.flatMap((encoded) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), + catch: storeError("Could not persist relay access tokens."), + }), + ), + ); + +const clearManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: storeError("Could not clear persisted relay access tokens."), +}); + +export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { + load: loadManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure("load")), + Effect.orElseSucceed(() => []), + Effect.withSpan("mobile.managedRelayTokenStore.load"), + ), + save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => + saveManagedRelayAccessTokens(entries).pipe( + Effect.tapError(logStoreFailure("save")), + Effect.ignore, + ), + ), + clear: clearManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure("clear")), + Effect.ignore, + Effect.withSpan("mobile.managedRelayTokenStore.clear"), + ), +}; diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index d5094d71b8b..05bf1a8fbcc 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; +import { + CloudPublicConfigMissingError, + hasTracingPublicConfig, + resolveCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -11,6 +16,12 @@ vi.mock("expo-constants", () => ({ })); describe("resolveCloudPublicConfig", () => { + it("reports the missing Clerk JWT template as structured configuration", () => { + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); + it("returns no cloud configuration for an unconfigured build", () => { expect(resolveCloudPublicConfig({})).toEqual({ clerk: { @@ -94,9 +105,9 @@ describe("resolveCloudPublicConfig", () => { }); it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); + expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", @@ -106,7 +117,7 @@ describe("resolveCloudPublicConfig", () => { ), ).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 7a8822eb9db..93a78fa4f44 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,6 +1,18 @@ import Constants from "expo-constants"; import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerk: { @@ -70,13 +82,13 @@ type Configured = { readonly [Key in keyof T]: NonNullable; }; -type MobileTracingPublicConfig = Omit & { +type TracingPublicConfig = Omit & { readonly observability: Configured; }; -export function hasMobileTracingPublicConfig( +export function hasTracingPublicConfig( config: CloudPublicConfig = resolveCloudPublicConfig(), -): config is MobileTracingPublicConfig { +): config is TracingPublicConfig { return Boolean( config.observability.tracesUrl && config.observability.tracesDataset && @@ -87,7 +99,7 @@ export function hasMobileTracingPublicConfig( export function resolveRelayClerkTokenOptions() { const { jwtTemplate } = resolveCloudPublicConfig().clerk; if (!jwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(jwtTemplate); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index dd26e2e6ffb..f5aa26be960 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); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {statusTraceId} + + + ) : null} ) : null} @@ -114,14 +134,14 @@ export function ConnectionEnvironmentRow(props: { className="gap-3 px-4 pb-4" > {props.environment.isRelayManaged ? ( - + Managed by T3 Cloud. Tunnel details update automatically. ) : ( <> Label @@ -133,13 +153,13 @@ export function ConnectionEnvironmentRow(props: { placeholderTextColor={placeholderColor} value={label} onChangeText={setLabel} - className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground" /> URL @@ -152,7 +172,7 @@ export function ConnectionEnvironmentRow(props: { placeholderTextColor={placeholderColor} value={url} onChangeText={setUrl} - className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" + className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground" /> @@ -166,7 +186,7 @@ export function ConnectionEnvironmentRow(props: { > Save diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index 1a03061e23f..8a692d80729 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -104,7 +104,7 @@ export function ConnectionSheetButton(props: { type="monochrome" /> {props.label} diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 60d86e0118c..ce5c6a6419e 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -11,12 +11,19 @@ import Animated, { import type { RemoteClientConnectionState } from "../../lib/connection"; -function statusDotTone(state: RemoteClientConnectionState): { +export type ConnectionStatusDotState = RemoteClientConnectionState; + +function statusDotTone(state: ConnectionStatusDotState): { readonly dotColor: string; readonly haloColor: string; } { switch (state) { - case "ready": + case "available": + return { + dotColor: "#9ca3af", + haloColor: "rgba(156,163,175,0.42)", + }; + case "connected": return { dotColor: "#34d399", haloColor: "rgba(52,211,153,0.48)", @@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): { dotColor: "#f59e0b", haloColor: "rgba(245,158,11,0.5)", }; - case "idle": - case "disconnected": + case "offline": + case "error": return { dotColor: "#ef4444", haloColor: "rgba(239,68,68,0.48)", @@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) { } export function ConnectionStatusDot(props: { - readonly state: RemoteClientConnectionState; + readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx new file mode 100644 index 00000000000..9b8c96d25ea --- /dev/null +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -0,0 +1,108 @@ +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!)} + > + {props.connection.traceId} + + + ) : null} + + + {props.connection.phase !== "offline" ? ( + + Retry now + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 5e17b469de2..0de49ceabf6 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection"; export function connectionTone(state: RemoteClientConnectionState): StatusTone { switch (state) { - case "ready": + case "connected": return { label: "Connected", pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", @@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", textClassName: "text-sky-700 dark:text-sky-300", }; - case "disconnected": + case "error": return { - label: "Disconnected", + label: "Connection failed", pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", textClassName: "text-rose-700 dark:text-rose-300", }; - case "idle": + case "offline": return { - label: "Idle", + label: "Offline", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + case "available": + return { + label: "Available", pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", textClassName: "text-neutral-600 dark:text-neutral-300", }; diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts new file mode 100644 index 00000000000..497af4bfac4 --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -0,0 +1,130 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { splitEnvironmentSections } from "./environmentSections"; + +function connectedEnvironment( + input: Omit, "environmentId"> & { + readonly environmentId: string; + readonly isRelayManaged: boolean; + }, +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(input.environmentId), + environmentLabel: input.environmentLabel ?? input.environmentId, + displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, + isRelayManaged: input.isRelayManaged, + connectionState: input.connectionState ?? "connected", + connectionError: input.connectionError ?? null, + connectionErrorTraceId: input.connectionErrorTraceId ?? null, + }; +} + +function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { + return { + environmentId: EnvironmentId.make(environmentId), + label: environmentId, + endpoint: { + httpBaseUrl: `https://${environmentId}.cloud.example.test/`, + wsBaseUrl: `wss://${environmentId}.cloud.example.test/ws`, + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("mobile environment settings sections", () => { + it("keeps saved relay-managed connections under T3 Cloud", () => { + const local = connectedEnvironment({ + environmentId: "environment-local", + isRelayManaged: false, + }); + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud, local], + cloudEnvironments: [ + cloudEnvironment("environment-cloud"), + cloudEnvironment("environment-new"), + ], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-new")]); + }); + + it("keeps saved relay-managed connections visible when cloud listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "reconnecting", + connectionError: "Environment did not respond before the connection timeout.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.localEnvironments).toEqual([]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps an available saved relay environment as a fallback when listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("does not duplicate a saved relay environment in the available cloud listing", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + const listedCloud = cloudEnvironment("environment-cloud"); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [listedCloud], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps failed relay environments in the local connection row", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "error", + connectionError: "Connection failed.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts new file mode 100644 index 00000000000..fc6db479c2f --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -0,0 +1,31 @@ +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +export interface EnvironmentSectionsInput { + readonly connectedEnvironments: ReadonlyArray; + readonly cloudEnvironments: ReadonlyArray | null; +} + +export interface EnvironmentSections { + readonly localEnvironments: ReadonlyArray; + readonly connectedCloudEnvironments: ReadonlyArray; + readonly availableCloudEnvironments: ReadonlyArray; +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = new Set( + input.connectedEnvironments.map((environment) => environment.environmentId), + ); + + return { + localEnvironments: input.connectedEnvironments.filter( + (environment) => !environment.isRelayManaged, + ), + connectedCloudEnvironments: input.connectedEnvironments.filter( + (environment) => environment.isRelayManaged, + ), + availableCloudEnvironments: (input.cloudEnvironments ?? []).filter( + (environment) => !savedEnvironmentIds.has(environment.environmentId), + ), + }; +} diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 028c46c1ce5..18b6c71a293 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { + extractPairingUrlFromQrPayload, + PairingQrPayloadEmptyError, + parsePairingUrl, +} from "./pairing"; describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { @@ -18,7 +22,8 @@ describe("extractPairingUrlFromQrPayload", () => { }); it("rejects empty qr payloads", () => { - expect(() => extractPairingUrlFromQrPayload(" ")).toThrow( + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( "Scanned QR code did not contain a pairing URL.", ); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index f7362900b0c..910efa7f256 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,7 +1,17 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( + "PairingQrPayloadEmptyError", + {}, +) { + override get message(): string { + return "Scanned QR code did not contain a pairing URL."; + } +} + export function buildPairingUrl(host: string, code: string): string { const h = host.trim(); const c = code.trim(); @@ -48,7 +58,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { export function extractPairingUrlFromQrPayload(payload: string): string { const trimmed = payload.trim(); if (!trimmed) { - throw new Error("Scanned QR code did not contain a pairing URL."); + throw new PairingQrPayloadEmptyError({}); } try { diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts new file mode 100644 index 00000000000..bad6b6f1720 --- /dev/null +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -0,0 +1,125 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { useCallback, useMemo } from "react"; + +import { environmentCatalog } from "../../connection/catalog"; +import { + connectPairingUrl as connectPairingUrlAtom, + updateBearerConnection, +} from "../../connection/onboarding"; +import { useEnvironments } from "../../state/environments"; +import { relayEnvironmentDiscovery } from "../../state/relay"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; + +export interface RelayEnvironmentView { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: "checking" | "online" | "offline" | "error"; + readonly status: RelayEnvironmentStatusResponse | null; + readonly error: string | null; + readonly traceId: string | null; +} + +export function useConnectionController() { + const { environments } = useEnvironments(); + const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); + const connectPairingUrlMutation = useAtomCommand(connectPairingUrlAtom, { + reportFailure: false, + }); + const updateBearer = useAtomCommand(updateBearerConnection, { reportFailure: false }); + const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); + const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); + const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const refreshRelayEnvironments = useAtomCommand( + relayEnvironmentDiscovery.refresh, + "relay environment refresh", + ); + + const connectedEnvironments = useMemo>( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const registeredIds = useMemo( + () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + [connectedEnvironments], + ); + const relayEnvironments = useMemo>( + () => + [...discovery.environments.values()].map((entry) => ({ + environment: entry.environment, + availability: entry.availability, + status: Option.getOrNull(entry.status), + error: Option.getOrNull(entry.error)?.message ?? null, + traceId: Option.getOrNull(entry.error)?.traceId ?? null, + })), + [discovery.environments], + ); + const availableRelayEnvironments = useMemo( + () => relayEnvironments.filter((entry) => !registeredIds.has(entry.environment.environmentId)), + [registeredIds, relayEnvironments], + ); + + const connectPairingUrl = useCallback( + (pairingUrl: string) => connectPairingUrlMutation(pairingUrl), + [connectPairingUrlMutation], + ); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [registerEnvironment], + ); + const removeEnvironment = useCallback( + (environmentId: EnvironmentId) => removeEnvironmentMutation(environmentId), + [removeEnvironmentMutation], + ); + const retryEnvironment = useCallback( + (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), + [retryEnvironmentMutation], + ); + const updateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => + updateBearer({ + environmentId, + label: updates.label, + httpBaseUrl: updates.displayUrl, + }), + [updateBearer], + ); + + return { + connectedEnvironments, + relayEnvironments, + availableRelayEnvironments, + relayDiscovery: { + isRefreshing: discovery.refreshing, + isOffline: discovery.offline, + error: Option.getOrNull(discovery.error)?.message ?? null, + errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null, + }, + connectPairingUrl, + connectRelayEnvironment, + removeEnvironment, + retryEnvironment, + updateEnvironment, + refreshRelayEnvironments, + }; +} diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 65e7539340d..975bf7be13d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -58,10 +58,23 @@ describe("resolveNativeReviewDiffView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); + + expect(resolveNativeReviewDiffView()).toBeNull(); expect(resolveNativeReviewDiffView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3ReviewDiffSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 7bd53f67748..7660a047752 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; interface ExpoGlobalWithViewConfig { @@ -110,6 +112,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly initialRowIndex?: number; readonly onDebug?: (event: NativeSyntheticEvent>) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; @@ -127,6 +130,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { } let cachedNativeReviewDiffView: ComponentType | undefined; +let nativeReviewDiffViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -139,6 +143,10 @@ export function resolveNativeReviewDiffView(): ComponentType( NATIVE_REVIEW_DIFF_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeReviewDiffViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx new file mode 100644 index 00000000000..ce762ab184e --- /dev/null +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -0,0 +1,173 @@ +import { useCallback, useMemo } from "react"; +import { + Markdown, + type CustomRenderers, + type NodeStyleOverrides, + type PartialMarkdownTheme, +} from "react-native-nitro-markdown"; +import { ScrollView, Text as NativeText, View } from "react-native"; + +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + hasNativeSelectableMarkdownText, + SelectableMarkdownText, + type NativeMarkdownTextStyle, +} from "../../native/SelectableMarkdownText"; + +interface MarkdownPreviewStyles { + readonly theme: PartialMarkdownTheme; + readonly styles: NodeStyleOverrides; + readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; +} + +function useMarkdownPreviewStyles(): MarkdownPreviewStyles { + const body = String(useThemeColor("--color-md-body")); + const strong = String(useThemeColor("--color-md-strong")); + const link = String(useThemeColor("--color-md-link")); + const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); + const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg")); + const codeBackground = String(useThemeColor("--color-md-code-bg")); + const codeText = String(useThemeColor("--color-md-code-text")); + const horizontalRule = String(useThemeColor("--color-md-hr")); + + return useMemo(() => { + const renderers: CustomRenderers = { + link: ({ href, children }) => ( + { + if (href) { + void tryOpenExternalUrl(href, "markdown-link"); + } + }} + style={{ + color: link, + fontFamily: "DMSans_500Medium", + textDecorationLine: "none", + }} + > + {children} + + ), + }; + + return { + theme: { + colors: { + text: body, + heading: strong, + link, + blockquote: blockquoteBorder, + border: horizontalRule, + surfaceLight: blockquoteBackground, + accent: link, + tableBorder: horizontalRule, + tableHeader: blockquoteBackground, + tableHeaderText: strong, + code: codeText, + codeBackground, + }, + }, + styles: { + text: { + color: body, + fontFamily: "DMSans_400Regular", + ...MOBILE_TYPOGRAPHY.body, + }, + heading: { + color: strong, + fontFamily: "DMSans_700Bold", + }, + strong: { + color: strong, + fontFamily: "DMSans_700Bold", + }, + link: { + color: link, + fontFamily: "DMSans_500Medium", + }, + blockquote: { + backgroundColor: blockquoteBackground, + borderLeftColor: blockquoteBorder, + borderLeftWidth: 3, + paddingLeft: 12, + }, + code: { + backgroundColor: codeBackground, + color: codeText, + fontFamily: "ui-monospace", + }, + codeBlock: { + backgroundColor: codeBackground, + borderRadius: 12, + color: codeText, + fontFamily: "ui-monospace", + padding: 12, + }, + hr: { + backgroundColor: horizontalRule, + }, + }, + renderers, + nativeTextStyle: { + color: body, + strongColor: strong, + mutedColor: body, + linkColor: link, + inlineCodeColor: codeText, + codeColor: codeText, + codeBackgroundColor: codeBackground, + codeBlockBackgroundColor: codeBackground, + fileTextColor: codeText, + skillTextColor: codeText, + quoteMarkerColor: blockquoteBorder, + dividerColor: horizontalRule, + ...MOBILE_TYPOGRAPHY.body, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, + }; + }, [ + blockquoteBackground, + blockquoteBorder, + body, + codeBackground, + codeText, + horizontalRule, + link, + strong, + ]); +} + +export function FileMarkdownPreview(props: { readonly markdown: string }) { + const styles = useMarkdownPreviewStyles(); + const onLinkPress = useCallback((href: string) => { + void tryOpenExternalUrl(href, "markdown-link"); + }, []); + + return ( + + + {hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {props.markdown} + + )} + + + ); +} diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx new file mode 100644 index 00000000000..3def77433b2 --- /dev/null +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -0,0 +1,189 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + buildFileTree, + defaultExpandedTreePaths, + flattenFileTree, + type VisibleFileTreeNode, +} from "./fileTree"; + +function ancestorPaths(path: string): ReadonlyArray { + const parts = path.split("/").filter(Boolean); + const ancestors: string[] = []; + for (let index = 1; index < parts.length; index += 1) { + ancestors.push(parts.slice(0, index).join("/")); + } + return ancestors; +} + +const FileTreeRow = memo(function FileTreeRow(props: { + readonly item: VisibleFileTreeNode; + readonly selectedPath: string | null; + readonly expanded: boolean; + readonly iconColor: string; + readonly onPressDirectory: (path: string) => void; + readonly onPressFile: (path: string) => void; +}) { + const { node, depth } = props.item; + const selected = node.kind === "file" && node.path === props.selectedPath; + + return ( + { + if (node.kind === "directory") { + props.onPressDirectory(node.path); + return; + } + props.onPressFile(node.path); + }} + className={cn( + "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", + selected && "bg-subtle-strong", + )} + style={{ paddingLeft: 8 + depth * 18 }} + > + {node.kind === "directory" ? ( + + ) : ( + + )} + + + {node.name} + + {node.kind === "directory" ? ( + + {node.children.length} + + ) : null} + + ); +}); + +export function FileTreeBrowser(props: { + readonly entries: ReadonlyArray; + readonly error: string | null; + readonly isPending: boolean; + readonly searchQuery: string; + readonly selectedPath: string | null; + readonly onRefresh: () => void; + readonly onSelectFile: (path: string) => void; +}) { + const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const iconColor = String(useThemeColor("--color-icon-muted")); + + const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); + const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); + const visibleNodes = useMemo( + () => + flattenFileTree({ + nodes: tree, + expanded: expandedPaths, + searchQuery: props.searchQuery, + }), + [expandedPaths, props.searchQuery, tree], + ); + + useEffect(() => { + setExpandedPaths((current) => { + if (current.size > 0 || defaultExpanded.size === 0) { + return current; + } + return new Set(defaultExpanded); + }); + }, [defaultExpanded]); + + useEffect(() => { + if (!props.selectedPath) { + return; + } + setExpandedPaths((current) => { + const next = new Set(current); + for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { + next.add(ancestor); + } + return next; + }); + }, [props.selectedPath]); + + const toggleDirectory = useCallback((path: string) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + return ( + + {props.error && props.entries.length === 0 ? ( + + Files unavailable + {props.error} + + ) : ( + item.node.path} + contentInsetAdjustmentBehavior="automatic" + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingVertical: 8 }} + refreshControl={ + + } + renderItem={({ item }) => ( + + )} + ListEmptyComponent={ + + {props.isPending ? ( + + ) : ( + <> + No files found + + {props.searchQuery.trim().length > 0 + ? "Try a different search." + : "The workspace file index is empty."} + + + )} + + } + /> + )} + + ); +} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx new file mode 100644 index 00000000000..b96d6515951 --- /dev/null +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -0,0 +1,258 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { ComponentType } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { LoadingStrip } from "../../components/LoadingStrip"; +import { + type NativeReviewDiffViewProps, + resolveNativeReviewDiffView, +} from "../diffs/nativeReviewDiffSurface"; +import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; +import { + REVIEW_DIFF_LINE_HEIGHT, + REVIEW_MONO_FONT_FAMILY, + renderVisibleWhitespace, +} from "../review/reviewDiffRendering"; +import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; +import { cn } from "../../lib/cn"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; +import { + buildNativeSourceRows, + buildNativeSourceTokens, + NATIVE_SOURCE_CONTENT_WIDTH, + NATIVE_SOURCE_ROW_HEIGHT, + NATIVE_SOURCE_STYLE, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; +import { sourceHighlightAtom } from "./sourceHighlightingState"; + +const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth; +const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); + +interface SourceFileSurfaceProps { + readonly contents: string; + readonly path: string; + readonly initialLine?: number | null; +} + +type SourceHighlightStatus = "highlighting" | "ready" | "error"; + +function splitSourceLines(contents: string): ReadonlyArray { + return contents.replace(/\r\n?/g, "\n").split("\n"); +} + +const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { + readonly index: number; + readonly line: string; + readonly tokens: ReadonlyArray | null; + readonly highlighted: boolean; +}) { + return ( + + + {props.index + 1} + + + {props.tokens && props.tokens.length > 0 + ? (() => { + let offset = 0; + return props.tokens.map((token) => { + const start = offset; + offset += token.content.length; + + const fontWeight = + token.fontStyle !== null && (token.fontStyle & 2) === 2 + ? ("700" as const) + : ("400" as const); + const fontStyle = + token.fontStyle !== null && (token.fontStyle & 1) === 1 + ? ("italic" as const) + : ("normal" as const); + + return ( + + {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} + + ); + }); + })() + : renderVisibleWhitespace(props.line || " ")} + + + ); +}); + +function useSourceFileModel(props: SourceFileSurfaceProps) { + const colorScheme = useColorScheme(); + const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; + const normalizedContents = useMemo( + () => props.contents.replace(/\r\n?/g, "\n"), + [props.contents], + ); + const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); + const targetIndex = + props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 + ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) + : null; + const highlightAtom = useMemo( + () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }), + [normalizedContents, props.path, theme], + ); + const highlightResult = useAtomValue(highlightAtom); + const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null; + const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult) + ? "error" + : AsyncResult.isSuccess(highlightResult) + ? "ready" + : "highlighting"; + + return { lines, status, targetIndex, theme, tokens }; +} + +function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { + if (props.status === "highlighting") { + return ; + } + if (props.status === "error") { + return ( + + Plain text + + ); + } + return null; +} + +function NativeSourceFileSurface( + props: SourceFileSurfaceProps & { + readonly NativeView: ComponentType; + }, +) { + const { NativeView } = props; + const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); + const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); + const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); + const selectedRowIdsJson = useMemo( + () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), + [targetIndex], + ); + const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); + + return ( + + + + + ); +} + +function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const listRef = useRef>(null); + + useEffect(() => { + if (targetIndex === null) { + return; + } + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); + }); + return () => cancelAnimationFrame(frame); + }, [props.path, targetIndex]); + + const renderLine = useCallback( + ({ item, index }: { item: string; index: number }) => ( + + ), + [targetIndex, tokens], + ); + + return ( + + + + String(index)} + initialNumToRender={80} + maxToRenderPerBatch={80} + windowSize={12} + getItemLayout={(_data, index) => ({ + length: SOURCE_LINE_HEIGHT, + offset: SOURCE_LINE_HEIGHT * index, + index, + })} + contentContainerStyle={{ + minWidth: "100%", + paddingBottom: REVIEW_DIFF_LINE_HEIGHT, + paddingTop: 8, + }} + renderItem={renderLine} + /> + + + ); +} + +export function SourceFileSurface(props: SourceFileSurfaceProps) { + const NativeView = resolveNativeReviewDiffView(); + return NativeView ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx new file mode 100644 index 00000000000..fba032c0369 --- /dev/null +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -0,0 +1,626 @@ +import Stack from "expo-router/stack"; +import { SymbolView } from "expo-symbols"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; +import { + EnvironmentId, + type ProjectListEntriesResult, + type ProjectReadFileResult, + ThreadId, +} from "@t3tools/contracts"; + +import { AppText as Text } from "../../components/AppText"; +import { CopyTextButton } from "../../components/CopyTextButton"; +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { cn } from "../../lib/cn"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { buildThreadFilesNavigation } from "../../lib/routes"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { SourceFileSurface } from "./SourceFileSurface"; +import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import { + basename, + fileBreadcrumbs, + isBrowserPreviewFile, + isImagePreviewFile, + isMarkdownPreviewFile, + isSvgImagePreviewFile, +} from "./filePath"; +import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; + +type FileViewMode = "preview" | "source"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function normalizeRoutePath(value: string | string[] | undefined): string | null { + const path = Array.isArray(value) ? value.join("/") : value; + if (path === undefined || path.trim().length === 0) { + return null; + } + return path; +} + +function normalizeRouteLine(value: string | null): number | null { + if (value === null) { + return null; + } + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function defaultViewMode(path: string | null): FileViewMode { + return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + ? "preview" + : "source"; +} + +function ModeButton(props: { + readonly active: boolean; + readonly icon: "doc.text" | "eye"; + readonly label: string; + readonly onPress: () => void; +}) { + const iconColor = String( + useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), + ); + + return ( + + + + {props.label} + + + ); +} + +function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { + const gradientId = `file-breadcrumb-${props.side}-fade`; + const isLeft = props.side === "left"; + + return ( + + + + + + + + + + + + ); +} + +function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const cardColor = String(useThemeColor("--color-card")); + const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); + const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); + const breadcrumbs = useMemo( + () => fileBreadcrumbs(props.projectName, props.relativePath), + [props.projectName, props.relativePath], + ); + const updateFadeVisibility = useCallback( + (metrics: Partial<(typeof scrollMetrics)["current"]>) => { + Object.assign(scrollMetrics.current, metrics); + const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; + const maxOffset = Math.max(0, contentWidth - viewportWidth); + const next = { + left: maxOffset > 1 && offsetX > 1, + right: maxOffset > 1 && offsetX < maxOffset - 1, + }; + + setFadeVisibility((current) => + current.left === next.left && current.right === next.right ? current : next, + ); + }, + [], + ); + + return ( + + { + updateFadeVisibility({ contentWidth }); + }} + onLayout={(event) => { + updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); + }} + onScroll={(event) => { + updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); + }} + scrollEventThrottle={16} + > + + {breadcrumbs.map((crumb, index) => ( + + {index > 0 ? ( + + ) : null} + + {crumb.label} + + + ))} + + + {fadeVisibility.left ? : null} + {fadeVisibility.right ? : null} + + ); +} + +function FilePreviewHeader(props: { + readonly activeMode: FileViewMode; + readonly showModeSelector: boolean; + readonly externalPreviewUri?: string | null; + readonly projectName: string; + readonly relativePath: string; + readonly onSetMode: (mode: FileViewMode) => void; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + + return ( + + + + + + {props.showModeSelector ? ( + + props.onSetMode("preview")} + /> + props.onSetMode("source")} + /> + {props.externalPreviewUri !== undefined ? ( + { + if (typeof props.externalPreviewUri === "string") { + void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); + } + }} + > + + + ) : null} + + ) : null} + + ); +} + +function FileContent(props: { + readonly activeMode: FileViewMode; + readonly previewUri: string | null; + readonly fileContents: string | null; + readonly fileError: string | null; + readonly relativePath: string; + readonly initialLine: number | null; + readonly truncated: boolean; +}) { + const isMarkdown = isMarkdownPreviewFile(props.relativePath); + const isBrowserFile = isBrowserPreviewFile(props.relativePath); + const isImageFile = isImagePreviewFile(props.relativePath); + + if (props.activeMode === "preview" && isImageFile) { + if (isSvgImagePreviewFile(props.relativePath)) { + return ; + } + return ( + + ); + } + + if (props.activeMode === "preview" && isBrowserFile) { + return ; + } + + if (props.fileError && props.fileContents === null) { + return ( + + + + ); + } + + if (props.fileContents === null) { + return ( + + + Loading file... + + ); + } + + return ( + + {props.truncated ? ( + + + Partial file + + + Preview limited to the first 1 MB of a truncated file. + + + ) : null} + {props.activeMode === "preview" && isMarkdown ? ( + + ) : ( + + )} + + ); +} + +function useThreadFilesWorkspace() { + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + }>(); + const routeEnvironmentId = firstRouteParam(params.environmentId); + const routeThreadId = firstRouteParam(params.threadId); + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const environmentId = + routeEnvironmentId !== null + ? EnvironmentId.make(routeEnvironmentId) + : (selectedThread?.environmentId ?? null); + const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null; + const project = selectedThreadProject as { + readonly title?: string; + readonly workspaceRoot?: string; + } | null; + + return { + cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null, + environmentId, + projectName: project?.title ?? "Files", + selectedThread, + threadId, + }; +} + +function FilesUnavailable() { + return ( + + + + + ); +} + +function FilesHeaderTitle(props: { readonly projectName: string }) { + const foregroundColor = String(useThemeColor("--color-foreground")); + const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary")); + + return ( + + + Files + + + {props.projectName} + + + ); +} + +function FilesToolbarBottomFade() { + const sheetColor = String(useThemeColor("--color-sheet")); + + if (process.env.EXPO_OS !== "ios") { + return null; + } + + return ( + + + + + + + + + + + + + ); +} + +export function ThreadFilesTreeScreen() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const entriesQuery = useEnvironmentQuery( + environmentId !== null && cwd !== null + ? projectEnvironment.listEntries({ + environmentId, + input: { cwd }, + }) + : null, + ); + const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + + const handleSelectFile = useCallback( + (path: string) => { + if (environmentId === null || threadId === null) { + return; + } + router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); + }, + [environmentId, router, threadId], + ); + + if (selectedThread === null || environmentId === null || threadId === null) { + return ; + } + + if (cwd === null) { + return ; + } + + return ( + + , + headerSearchBarOptions: { + allowToolbarIntegration: true, + autoCapitalize: "none", + hideNavigationBar: false, + placeholder: "Search files", + onChangeText: (event) => { + setSearchQuery(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + setSearchQuery(""); + }, + }, + }} + /> + + + + + + + + + + ); +} + +export function ThreadFileScreen() { + const params = useLocalSearchParams<{ + line?: string | string[]; + path?: string | string[]; + }>(); + const relativePath = normalizeRoutePath(params.path); + const targetLine = normalizeRouteLine(firstRouteParam(params.line)); + const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); + const [modeOverride, setModeOverride] = useState<{ + readonly path: string; + readonly mode: FileViewMode; + } | null>(null); + const [previewRevision, setPreviewRevision] = useState(0); + const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); + const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const canPreview = + relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + const activeMode = + relativePath !== null && modeOverride?.path === relativePath + ? modeOverride.mode + : defaultViewMode(relativePath); + const resolvedActiveMode = canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; + const assetPreviewUri = useWorkspaceFileAssetUrl({ + cwd, + environmentId, + relativePath: assetPreviewPath, + threadId, + }); + const previewUri = + assetPreviewUri === null || previewRevision === 0 + ? assetPreviewUri + : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; + const needsFileContents = + relativePath !== null && + (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); + const fileQuery = useEnvironmentQuery( + environmentId !== null && cwd !== null && relativePath !== null && needsFileContents + ? projectEnvironment.readFile({ + environmentId, + input: { cwd, relativePath }, + }) + : null, + ); + const fileData = fileQuery.data as ProjectReadFileResult | null; + + if (selectedThread === null || environmentId === null || threadId === null) { + return ; + } + + if (cwd === null) { + return ; + } + + if (relativePath === null) { + return ( + + + + + ); + } + + return ( + + + + + { + if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { + setPreviewRevision((current) => current + 1); + return; + } + fileQuery.refresh(); + }} + /> + + { + setModeOverride({ path: relativePath, mode }); + }} + /> + + + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx new file mode 100644 index 00000000000..73eca66bf99 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -0,0 +1,118 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo, useState } from "react"; +import { ActivityIndicator, Image, Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { workspaceFileImageAtom } from "./workspace-file-image-cache"; + +function ResolvedWorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string; +}) { + const [loadError, setLoadError] = useState(null); + const [fullScreenVisible, setFullScreenVisible] = useState(false); + const imageSource = useMemo( + () => ({ uri: props.uri, cache: "force-cache" as const }), + [props.uri], + ); + const fullScreenImages = useMemo(() => [imageSource], [imageSource]); + + return ( + + setFullScreenVisible(true)} + > + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + + + {loadError !== null ? ( + + + + ) : null} + + setFullScreenVisible(false)} + swipeToCloseEnabled + doubleTapToZoomEnabled + /> + + ); +} + +function CachedWorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string; +}) { + const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); + const imageResult = useAtomValue(imageAtom); + + if (AsyncResult.isFailure(imageResult)) { + return ( + + + + ); + } + + if (!AsyncResult.isSuccess(imageResult)) { + return ( + + + Loading image... + + ); + } + + return ( + + ); +} + +export function WorkspaceFileImagePreview(props: { + readonly accessibilityLabel: string; + readonly uri: string | null; +}) { + if (props.uri === null) { + return ( + + + + Preparing image preview... + + + ); + } + + return ( + + ); +} diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx new file mode 100644 index 00000000000..6d03a23d52a --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { ActivityIndicator, View } from "react-native"; +import { WebView } from "react-native-webview"; + +import { AppText as Text } from "../../components/AppText"; +import { LoadingStrip } from "../../components/LoadingStrip"; + +export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { + const [loadProgress, setLoadProgress] = useState(0); + const [loadError, setLoadError] = useState(null); + + if (props.uri === null) { + return ( + + + Preparing preview... + + ); + } + + return ( + + {loadProgress > 0 && loadProgress < 1 ? : null} + {loadError ? ( + + Preview failed + {loadError} + + ) : null} + { + setLoadProgress(event.nativeEvent.progress); + }} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={() => { + setLoadProgress(0); + }} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The file could not be rendered."); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts new file mode 100644 index 00000000000..af0ace61fc0 --- /dev/null +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isBrowserPreviewFile, + isImagePreviewFile, + isSvgImagePreviewFile, + resolveWorkspaceRelativeFilePath, +} from "./filePath"; + +describe("resolveWorkspaceRelativeFilePath", () => { + it("keeps normalized workspace-relative paths", () => { + expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts"); + }); + + it("converts absolute paths inside the workspace", () => { + expect( + resolveWorkspaceRelativeFilePath("/Users/julius/repo", "/Users/julius/repo/src/main.ts"), + ).toBe("src/main.ts"); + expect(resolveWorkspaceRelativeFilePath("C:\\repo", "c:\\repo\\src\\main.ts")).toBe( + "src/main.ts", + ); + }); + + it("rejects paths outside the workspace", () => { + expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); + }); +}); + +describe("file preview types", () => { + it("recognizes browser and image previews", () => { + expect(isBrowserPreviewFile("reports/summary.html")).toBe(true); + expect(isImagePreviewFile("assets/icon.png")).toBe(true); + expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true); + expect(isImagePreviewFile("src/image.ts")).toBe(false); + }); + + it("identifies SVG images that need web rendering", () => { + expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true); + expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts new file mode 100644 index 00000000000..385d5c139ee --- /dev/null +++ b/apps/mobile/src/features/files/filePath.ts @@ -0,0 +1,116 @@ +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, +} from "@t3tools/shared/filePreview"; + +export interface FileBreadcrumb { + readonly label: string; + readonly path: string; + readonly kind: "project" | "directory" | "file"; +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsAbsolutePath(value); +} + +function isWindowsPathStyle(value: string): boolean { + return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); +} + +function joinPath(base: string, next: string, separator: "/" | "\\"): string { + const cleanBase = base.replace(/[\\/]+$/, ""); + if (separator === "\\") { + return `${cleanBase}\\${next.replaceAll("/", "\\")}`; + } + return `${cleanBase}/${next.replace(/^\/+/, "")}`; +} + +export function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts.at(-1) ?? path; +} + +export function resolveWorkspaceFilePath(cwd: string, relativePath: string): string { + if (isAbsolutePath(relativePath)) { + return relativePath; + } + + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + return joinPath(cwd, relativePath, separator); +} + +function normalizeRelativePath(value: string): string | null { + const segments: string[] = []; + for (const segment of value.replaceAll("\\", "/").split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.length > 0 ? segments.join("/") : null; +} + +export function resolveWorkspaceRelativeFilePath( + workspaceRoot: string | null | undefined, + targetPath: string, +): string | null { + if (!isAbsolutePath(targetPath)) { + if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) { + return null; + } + return normalizeRelativePath(targetPath); + } + if (!workspaceRoot) { + return null; + } + + const normalizedTarget = targetPath.replaceAll("\\", "/"); + const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, ""); + const caseInsensitive = isWindowsAbsolutePath(targetPath) || isWindowsAbsolutePath(workspaceRoot); + const comparableTarget = caseInsensitive ? normalizedTarget.toLowerCase() : normalizedTarget; + const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!comparableTarget.startsWith(`${comparableRoot}/`)) { + return null; + } + + return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); +} + +export function isBrowserPreviewFile(path: string): boolean { + return isWorkspaceBrowserPreviewPath(path); +} + +export function isImagePreviewFile(path: string): boolean { + return isWorkspaceImagePreviewPath(path); +} + +export function isSvgImagePreviewFile(path: string): boolean { + return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +} + +export function isMarkdownPreviewFile(path: string): boolean { + return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +} + +export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { + const parts = relativePath.split("/").filter(Boolean); + return [ + { label: projectName, path: "", kind: "project" }, + ...parts.map((part, index) => ({ + label: part, + path: parts.slice(0, index + 1).join("/"), + kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), + })), + ]; +} diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 00000000000..85383514cb5 --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProjectEntry } from "@t3tools/contracts"; + +import { + buildFileTree, + countFileNodes, + defaultExpandedTreePaths, + firstFilePath, + flattenFileTree, +} from "./fileTree"; + +const entries = [ + { kind: "file", path: "README.md" }, + { kind: "directory", path: "src" }, + { kind: "file", path: "src/index.ts" }, + { kind: "file", path: "src/components/App.tsx" }, + { kind: "file", path: "package.json" }, +] satisfies ReadonlyArray; + +describe("mobile file tree helpers", () => { + it("builds a deterministic hierarchy with directories before files", () => { + const tree = buildFileTree(entries); + + expect(tree.map((node) => `${node.kind}:${node.path}`)).toEqual([ + "directory:src", + "file:package.json", + "file:README.md", + ]); + expect(tree[0]?.children.map((node) => `${node.kind}:${node.path}`)).toEqual([ + "directory:src/components", + "file:src/index.ts", + ]); + expect(countFileNodes(tree)).toBe(4); + expect(firstFilePath(tree)).toBe("src/components/App.tsx"); + }); + + it("flattens expanded directories and hides collapsed descendants", () => { + const tree = buildFileTree(entries); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(["src"]), + }).map((item) => `${item.depth}:${item.node.path}`), + ).toEqual(["0:src", "1:src/components", "1:src/index.ts", "0:package.json", "0:README.md"]); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + }).map((item) => item.node.path), + ).toEqual(["src", "package.json", "README.md"]); + }); + + it("includes matching descendants and their ancestors during search", () => { + const tree = buildFileTree(entries); + + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + searchQuery: "app", + }).map((item) => item.node.path), + ).toEqual(["src", "src/components", "src/components/App.tsx"]); + }); + + it("supports fuzzy, whitespace-separated path queries", () => { + const tree = buildFileTree([ + { + kind: "file", + path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md", + }, + { + kind: "file", + path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts", + }, + { kind: "directory", path: "apps/web/src/components/chat" }, + { kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" }, + { kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" }, + { kind: "file", path: "apps/web/src/components/chat/Composer.tsx" }, + ]); + + const expectedPaths = [ + "apps", + "apps/web", + "apps/web/src", + "apps/web/src/components", + "apps/web/src/components/chat", + "apps/web/src/components/chat/ChatHeader.test.ts", + "apps/web/src/components/chat/ChatHeader.tsx", + ]; + + for (const searchQuery of ["chat hea", "cht hdr"]) { + expect( + flattenFileTree({ + nodes: tree, + expanded: new Set(), + searchQuery, + }).map((item) => item.node.path), + ).toEqual(expectedPaths); + } + }); + + it("expands top-level directories by default", () => { + const tree = buildFileTree(entries); + + expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]); + }); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts new file mode 100644 index 00000000000..28b5822aaa0 --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.ts @@ -0,0 +1,220 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +export interface FileTreeNode { + readonly path: string; + readonly name: string; + readonly kind: ProjectEntry["kind"]; + readonly children: ReadonlyArray; + readonly searchSegments: ReadonlyArray; + readonly searchWords: ReadonlyArray; +} + +export interface VisibleFileTreeNode { + readonly node: FileTreeNode; + readonly depth: number; +} + +interface MutableFileTreeNode { + path: string; + name: string; + kind: ProjectEntry["kind"]; + children: Map; +} + +function createMutableNode( + path: string, + name: string, + kind: ProjectEntry["kind"], +): MutableFileTreeNode { + return { + path, + name, + kind, + children: new Map(), + }; +} + +function splitSearchWords(value: string): ReadonlyArray { + return value + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); +} + +function buildNodeSearchTerms(path: string): { + readonly segments: ReadonlyArray; + readonly words: ReadonlyArray; +} { + const segments: string[] = []; + const words: string[] = []; + + for (const segment of path.split("/")) { + if (!segment) { + continue; + } + segments.push(segment.toLowerCase()); + words.push(...splitSearchWords(segment)); + } + + return { segments, words }; +} + +function freezeNode(node: MutableFileTreeNode): FileTreeNode { + const searchTerms = buildNodeSearchTerms(node.path); + return { + path: node.path, + name: node.name, + kind: node.kind, + children: [...node.children.values()].sort(compareNodes).map(freezeNode), + searchSegments: searchTerms.segments, + searchWords: searchTerms.words, + }; +} + +function compareNodes( + left: Pick, + right: Pick, +): number { + if (left.kind !== right.kind) { + return left.kind === "directory" ? -1 : 1; + } + return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); +} + +export function buildFileTree(entries: ReadonlyArray): ReadonlyArray { + const root = createMutableNode("", "", "directory"); + + for (const entry of entries) { + const parts = entry.path.split("/").filter(Boolean); + if (parts.length === 0) { + continue; + } + + let current = root; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part) { + continue; + } + + const path = parts.slice(0, index + 1).join("/"); + const isLeaf = index === parts.length - 1; + const kind = isLeaf ? entry.kind : "directory"; + let child = current.children.get(part); + if (!child) { + child = createMutableNode(path, part, kind); + current.children.set(part, child); + } else if (isLeaf) { + child.kind = entry.kind; + } + current = child; + } + } + + return [...root.children.values()].sort(compareNodes).map(freezeNode); +} + +export function countFileNodes(nodes: ReadonlyArray): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "file") { + count += 1; + } else { + count += countFileNodes(node.children); + } + } + return count; +} + +export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { + const expanded = new Set(); + for (const node of nodes) { + if (node.kind === "directory") { + expanded.add(node.path); + } + } + return expanded; +} + +function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean { + return ( + scoreQueryMatch({ + value, + query: token, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + ...(fuzzy ? { fuzzyBase: 100 } : {}), + boundaryMarkers: ["/", "-", "_", "."], + }) !== null + ); +} + +function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray): boolean { + return tokens.every( + (token) => + node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) || + node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)), + ); +} + +function flattenNode( + output: VisibleFileTreeNode[], + node: FileTreeNode, + depth: number, + expanded: ReadonlySet, + searchTokens: ReadonlyArray, +): boolean { + const isSearching = searchTokens.length > 0; + const matches = isSearching && nodeMatchesSearch(node, searchTokens); + let descendantMatches = false; + const childOutput: VisibleFileTreeNode[] = []; + + if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { + for (const child of node.children) { + if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) { + descendantMatches = true; + } + } + } + + const visible = !isSearching || matches || descendantMatches; + if (!visible) { + return false; + } + + output.push({ node, depth }); + output.push(...childOutput); + return matches || descendantMatches; +} + +export function flattenFileTree(input: { + readonly nodes: ReadonlyArray; + readonly expanded: ReadonlySet; + readonly searchQuery?: string; +}): ReadonlyArray { + const output: VisibleFileTreeNode[] = []; + const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? ""); + const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean); + for (const node of input.nodes) { + flattenNode(output, node, 0, input.expanded, searchTokens); + } + return output; +} + +export function firstFilePath(nodes: ReadonlyArray): string | null { + for (const node of nodes) { + if (node.kind === "file") { + return node.path; + } + const child = firstFilePath(node.children); + if (child !== null) { + return child; + } + } + return null; +} diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts new file mode 100644 index 00000000000..0e7d478c6bd --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildNativeSourceRows, + buildNativeSourceTokens, + NATIVE_SOURCE_ROW_HEIGHT, + NATIVE_SOURCE_STYLE, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; +import { + NATIVE_REVIEW_DIFF_ROW_HEIGHT, + NATIVE_REVIEW_DIFF_STYLE, +} from "../review/nativeReviewDiffAdapter"; + +describe("nativeSourceFileAdapter", () => { + it("uses the same compact code typography as the diff viewer", () => { + expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); + expect(NATIVE_SOURCE_STYLE).toMatchObject({ + rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, + gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, + codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, + textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, + codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, + codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, + lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, + lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, + }); + }); + + it("maps plain source lines onto context rows with stable line numbers", () => { + expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ + { + kind: "line", + id: nativeSourceRowId(0), + fileId: "source-file", + content: "const value = 1;", + change: "context", + newLineNumber: 1, + }, + { + kind: "line", + id: nativeSourceRowId(1), + fileId: "source-file", + content: " return value;", + change: "context", + newLineNumber: 2, + }, + ]); + }); + + it("maps cached source tokens to the same row identifiers", () => { + expect( + buildNativeSourceTokens([ + [{ content: "const", color: "#ff0000", fontStyle: 2 }], + [{ content: "\tvalue", color: null, fontStyle: null }], + ]), + ).toEqual({ + [nativeSourceRowId(0)]: [{ content: "const", color: "#ff0000", fontStyle: 2 }], + [nativeSourceRowId(1)]: [{ content: " value", color: null, fontStyle: null }], + }); + }); + + it("clears native tokens while highlighting is unavailable", () => { + expect(buildNativeSourceTokens(null)).toEqual({}); + }); +}); diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts new file mode 100644 index 00000000000..9bb341e2909 --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -0,0 +1,67 @@ +import type { + NativeReviewDiffRow, + NativeReviewDiffStyle, + NativeReviewDiffToken, +} from "../diffs/nativeReviewDiffSurface"; +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import type { SourceHighlightTokens } from "./sourceHighlightingState"; + +export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; +export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; + +export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = { + rowHeight: NATIVE_SOURCE_ROW_HEIGHT, + contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, + changeBarWidth: 0, + gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, + codePadding: MOBILE_CODE_SURFACE.codePadding, + textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, + codeFontSize: MOBILE_CODE_SURFACE.fontSize, + codeFontWeight: "regular", + lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, + lineNumberFontWeight: "regular", + emptyStateFontSize: MOBILE_TYPOGRAPHY.label.fontSize, + emptyStateFontWeight: "medium", +}; + +const SOURCE_FILE_ID = "source-file"; + +function expandTabs(value: string): string { + return value.replace(/\t/g, " "); +} + +export function nativeSourceRowId(index: number): string { + return `source-line:${index}`; +} + +export function buildNativeSourceRows( + lines: ReadonlyArray, +): ReadonlyArray { + return lines.map((line, index) => ({ + kind: "line", + id: nativeSourceRowId(index), + fileId: SOURCE_FILE_ID, + content: expandTabs(line), + change: "context", + newLineNumber: index + 1, + })); +} + +export function buildNativeSourceTokens( + tokenLines: SourceHighlightTokens | null, +): Readonly>> { + if (tokenLines === null) { + return {}; + } + + return Object.fromEntries( + tokenLines.map((tokens, index) => [ + nativeSourceRowId(index), + tokens.map((token) => ({ + content: expandTabs(token.content), + color: token.color, + fontStyle: token.fontStyle, + })), + ]), + ); +} diff --git a/apps/mobile/src/features/files/sourceHighlightingState.test.ts b/apps/mobile/src/features/files/sourceHighlightingState.test.ts new file mode 100644 index 00000000000..6c4c00e1663 --- /dev/null +++ b/apps/mobile/src/features/files/sourceHighlightingState.test.ts @@ -0,0 +1,123 @@ +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createSourceHighlightAtomFamily, + type SourceHighlightTokens, +} from "./sourceHighlightingState"; + +const highlightedTokens: SourceHighlightTokens = [ + [{ content: "const", color: "#0000ff", fontStyle: null }], +]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("sourceHighlightingState", () => { + it("reuses completed highlighting across equivalent route remounts", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 1_000 }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const input = { + path: "src/example.ts", + contents: "const value = 1;", + theme: "light" as const, + }; + const firstAtom = sourceHighlightAtom(input); + const firstUnmount = registry.mount(firstAtom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); + }); + firstUnmount(); + + const remountedAtom = sourceHighlightAtom({ ...input }); + const secondUnmount = registry.mount(remountedAtom); + + expect(remountedAtom).toBe(firstAtom); + expect(AsyncResult.isSuccess(registry.get(remountedAtom))).toBe(true); + expect(highlight).toHaveBeenCalledTimes(1); + + secondUnmount(); + registry.dispose(); + }); + + it("does not reuse highlighting when the source contents change", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); + const registry = AtomRegistry.make(); + const firstAtom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const secondAtom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 2;", + theme: "light", + }); + const firstUnmount = registry.mount(firstAtom); + const secondUnmount = registry.mount(secondAtom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); + expect(AsyncResult.isSuccess(registry.get(secondAtom))).toBe(true); + }); + expect(secondAtom).not.toBe(firstAtom); + expect(highlight).toHaveBeenCalledTimes(2); + + firstUnmount(); + secondUnmount(); + registry.dispose(); + }); + + it("recomputes highlighting after the idle cache entry expires", async () => { + const highlight = vi.fn(async () => highlightedTokens); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 5 }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const atom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const firstUnmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); + }); + firstUnmount(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const secondUnmount = registry.mount(atom); + await vi.waitFor(() => { + expect(highlight).toHaveBeenCalledTimes(2); + expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); + }); + + secondUnmount(); + registry.dispose(); + }); + + it("exposes highlighter errors as a failed async result", async () => { + const highlight = vi.fn(async () => { + throw new Error("highlight failed"); + }); + const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); + const registry = AtomRegistry.make(); + const atom = sourceHighlightAtom({ + path: "src/example.ts", + contents: "const value = 1;", + theme: "light", + }); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + + unmount(); + registry.dispose(); + }); +}); diff --git a/apps/mobile/src/features/files/sourceHighlightingState.ts b/apps/mobile/src/features/files/sourceHighlightingState.ts new file mode 100644 index 00000000000..43363115bc8 --- /dev/null +++ b/apps/mobile/src/features/files/sourceHighlightingState.ts @@ -0,0 +1,50 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { + highlightSourceFile, + type ReviewDiffTheme, + type ReviewHighlightedToken, +} from "../review/shikiReviewHighlighter"; + +const SOURCE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000; + +export interface SourceHighlightInput { + readonly path: string; + readonly contents: string; + readonly theme: ReviewDiffTheme; +} + +export type SourceHighlightTokens = ReadonlyArray>; + +type SourceHighlighter = (input: SourceHighlightInput) => Promise; + +class SourceHighlightCacheKey extends Data.Class {} + +class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{ + readonly cause: unknown; +}> {} + +export function createSourceHighlightAtomFamily(options?: { + readonly highlight?: SourceHighlighter; + readonly idleTtlMs?: number; +}) { + const highlight = options?.highlight ?? highlightSourceFile; + const idleTtlMs = options?.idleTtlMs ?? SOURCE_HIGHLIGHT_IDLE_TTL_MS; + const family = Atom.family((request: SourceHighlightCacheKey) => + Atom.make( + Effect.tryPromise({ + try: () => highlight(request), + catch: (cause) => new SourceHighlightError({ cause }), + }), + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:source-highlight:${request.theme}:${request.path}`), + ), + ); + + return (input: SourceHighlightInput) => family(new SourceHighlightCacheKey(input)); +} + +export const sourceHighlightAtom = createSourceHighlightAtomFamily(); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.test.ts b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts new file mode 100644 index 00000000000..4acb67361a8 --- /dev/null +++ b/apps/mobile/src/features/files/workspace-file-image-cache.test.ts @@ -0,0 +1,64 @@ +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; + +describe("workspaceFileImageAtom", () => { + it("reuses a prefetched image across route remounts", async () => { + const prefetch = vi.fn(async () => true); + const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); + const registry = AtomRegistry.make({ timeoutResolution: 1 }); + const first = imageAtom("https://example.test/image.png"); + const firstUnmount = registry.mount(first); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); + }); + firstUnmount(); + + const remounted = imageAtom("https://example.test/image.png"); + const secondUnmount = registry.mount(remounted); + + expect(remounted).toBe(first); + expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); + expect(prefetch).toHaveBeenCalledTimes(1); + + secondUnmount(); + registry.dispose(); + }); + + it("prefetches different asset URLs independently", async () => { + const prefetch = vi.fn(async () => true); + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); + const registry = AtomRegistry.make(); + const first = imageAtom("https://example.test/first.png"); + const second = imageAtom("https://example.test/second.png"); + const firstUnmount = registry.mount(first); + const secondUnmount = registry.mount(second); + + await vi.waitFor(() => { + expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); + expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); + }); + expect(prefetch).toHaveBeenCalledTimes(2); + + firstUnmount(); + secondUnmount(); + registry.dispose(); + }); + + it("exposes prefetch failures", async () => { + const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); + const registry = AtomRegistry.make(); + const atom = imageAtom("https://example.test/missing.png"); + const unmount = registry.mount(atom); + + await vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); + }); + + unmount(); + registry.dispose(); + }); +}); diff --git a/apps/mobile/src/features/files/workspace-file-image-cache.ts b/apps/mobile/src/features/files/workspace-file-image-cache.ts new file mode 100644 index 00000000000..3f58f65b46c --- /dev/null +++ b/apps/mobile/src/features/files/workspace-file-image-cache.ts @@ -0,0 +1,48 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; + +type ImagePrefetch = (uri: string) => Promise; + +class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} + +export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ + readonly cause?: unknown; + readonly uri: string; +}> {} + +async function prefetchWithNativeImage(uri: string): Promise { + const { Image } = await import("react-native"); + return Image.prefetch(uri); +} + +export function createWorkspaceFileImageAtomFamily(options?: { + readonly idleTtlMs?: number; + readonly prefetch?: ImagePrefetch; +}) { + const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; + const prefetch = options?.prefetch ?? prefetchWithNativeImage; + const family = Atom.family((key: WorkspaceImageCacheKey) => + Atom.make( + Effect.tryPromise({ + try: async () => { + const cached = await prefetch(key.uri); + if (!cached) { + throw new WorkspaceImagePrefetchError({ uri: key.uri }); + } + return key.uri; + }, + catch: (cause) => + cause instanceof WorkspaceImagePrefetchError + ? cause + : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), + }), + ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), + ); + + return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); +} + +export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts new file mode 100644 index 00000000000..70ea3e43582 --- /dev/null +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useAssetUrl } from "../../state/assets"; +import { resolveWorkspaceFilePath } from "./filePath"; + +export function useWorkspaceFileAssetUrl(props: { + readonly cwd: string | null; + readonly environmentId: EnvironmentId | null; + readonly relativePath: string | null; + readonly threadId: ThreadId | null; +}) { + const absolutePath = useMemo( + () => + props.cwd !== null && props.relativePath !== null + ? resolveWorkspaceFilePath(props.cwd, props.relativePath) + : null, + [props.cwd, props.relativePath], + ); + + return useAssetUrl( + props.environmentId, + absolutePath !== null && props.threadId !== null + ? { + _tag: "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx new file mode 100644 index 00000000000..9757d5fbf91 --- /dev/null +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -0,0 +1,245 @@ +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "@t3tools/contracts"; +import { Stack } from "expo-router"; +import { Text as RNText, View } from "react-native"; + +import { useThemeColor } from "../../lib/useThemeColor"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import type { HomeProjectSortOrder } from "./homeThreadList"; + +export interface HomeHeaderEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +const PROJECT_SORT_OPTIONS: ReadonlyArray<{ + readonly value: HomeProjectSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +const THREAD_SORT_OPTIONS: ReadonlyArray<{ + readonly value: SidebarThreadSortOrder; + readonly label: string; +}> = [ + { value: "updated_at", label: "Last user message" }, + { value: "created_at", label: "Created at" }, +]; + +const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ + readonly value: SidebarProjectGroupingMode; + readonly label: string; + readonly subtitle: string; +}> = [ + { + value: "repository", + label: "Group by repository", + subtitle: "Combine matching repositories across environments", + }, + { + value: "repository_path", + label: "Group by repository path", + subtitle: "Combine only matching paths within a repository", + }, + { + value: "separate", + label: "Keep separate", + subtitle: "Show every project path separately", + }, +]; + +export function HomeHeader(props: { + readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly onSearchQueryChange: (query: string) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; + readonly onOpenSettings: () => void; + readonly onStartNewTask: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const hasCustomListOptions = + props.selectedEnvironmentId !== null || + props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || + props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || + props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; + + return ( + <> + { + props.onSearchQueryChange(event.nativeEvent.text); + }, + onCancelButtonPress: () => { + props.onSearchQueryChange(""); + }, + allowToolbarIntegration: true, + }, + }} + /> + + + + + + T3 Code + + + + Alpha + + + + + + + + + + Environment + props.onEnvironmentChange(null)} + subtitle="Show threads from every environment" + > + All environments + + {props.environments.map((environment) => ( + props.onEnvironmentChange(environment.environmentId)} + > + {environment.label} + + ))} + + + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + + + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + + + + Group projects + {PROJECT_GROUPING_OPTIONS.map((option) => ( + props.onProjectGroupingModeChange(option.value)} + subtitle={option.subtitle} + > + {option.label} + + ))} + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cdae41668a0..7ee5660edf1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,55 +1,65 @@ +import { + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import type { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - VcsStatusState, -} from "@t3tools/client-runtime"; + EnvironmentId, + SidebarProjectGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; -import { useCallback, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { + Easing, + LinearTransition, + type ExitAnimationsValues, + withDelay, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; -import type { RemoteCatalogState } from "../../state/use-remote-catalog"; -import { useVcsStatus } from "../../state/use-vcs-status"; import { threadStatusTone } from "../threads/threadPresentation"; +import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { + THREAD_SWIPE_ACTIONS_WIDTH, + THREAD_SWIPE_SPRING, + ThreadSwipeActions, +} from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly catalogState: RemoteCatalogState; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; readonly searchQuery: string; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onAddConnection: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onOpenEnvironments: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; } -interface ProjectGroup { - readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; -} - -const projectGroupActivityOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.Number), - }), - (group: ProjectGroup) => ({ - activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(), - }), -); - /* ─── Status indicator colors ────────────────────────────────────────── */ -function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } { +function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { switch (thread.session?.status) { case "running": return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; @@ -65,13 +75,40 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s } const COLLAPSED_THREAD_LIMIT = 6; +const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); + +function threadRowExit(values: ExitAnimationsValues) { + "worklet"; + + return { + initialValues: { + height: values.currentHeight, + opacity: 1, + originX: values.currentOriginX, + }, + animations: { + height: withDelay( + 90, + withTiming(0, { + duration: 170, + easing: Easing.inOut(Easing.cubic), + }), + ), + opacity: withDelay(80, withTiming(0, { duration: 100 })), + originX: withTiming(values.currentOriginX - values.windowWidth, { + duration: 190, + easing: Easing.out(Easing.cubic), + }), + }, + }; +} function deriveEmptyState(props: { - readonly catalogState: RemoteCatalogState; + readonly catalogState: WorkspaceState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -79,7 +116,7 @@ function deriveEmptyState(props: { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", @@ -87,7 +124,12 @@ function deriveEmptyState(props: { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -127,10 +169,9 @@ function deriveEmptyState(props: { /* ─── Project group header ───────────────────────────────────────────── */ function ProjectGroupLabel(props: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; + readonly title: string; readonly totalThreadCount: number; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { @@ -139,24 +180,23 @@ function ProjectGroupLabel(props: { return ( - {props.project.title} + {props.title} {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -167,134 +207,239 @@ function ProjectGroupLabel(props: { ); } -/* ─── Git summary line ──────────────────────────────────────────────── */ - -function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray { - if (!gitStatus.data) return []; - const { data } = gitStatus; - const parts: string[] = []; - if (data.hasWorkingTreeChanges) { - parts.push(`${data.workingTree.files.length} changed`); - } - if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); - if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); - if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); - return parts; -} - /* ─── Thread row ─────────────────────────────────────────────────────── */ function ThreadRow(props: { - readonly thread: EnvironmentScopedThreadShell; - readonly projectCwd: string | null; + readonly thread: EnvironmentThreadShell; + readonly environmentLabel: string | null; readonly onPress: () => void; + readonly onArchive: () => void; + readonly onDelete: () => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly isLast: boolean; }) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const { width: windowWidth } = useWindowDimensions(); const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const cardColor = useThemeColor("--color-card"); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); - const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); + const timestamp = relativeTime( + props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt, + ); const branch = props.thread.branch; - - // Subscribe to live git status — only when thread has a branch set. - // Threads sharing the same cwd share one WS subscription via ref-counting. - const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; - const gitStatus = useVcsStatus({ - environmentId: cwd ? props.thread.environmentId : null, - cwd, - }); - const gitParts = gitSummaryParts(gitStatus); + const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => + Boolean(part), + ); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); return ( - ({ opacity: pressed ? 0.7 : 1 })}> - { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) { + return; + } + + props.onSwipeableWillOpen(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + > + { + swipeableRef.current?.close(); + props.onPress(); }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - {/* Git status indicator */} - - - - {/* Content */} - - {/* Title + Status + Timestamp */} - - - {props.thread.title} - - - - - {tone.label} - - - - {timestamp} - - + + - {/* Branch + git info */} - {branch ? ( - - + + - {branch} + {props.thread.title} - {gitParts.length > 0 ? ( - - {" · " + gitParts.join(" · ")} + + + + {tone.label} + + + + {timestamp} - ) : null} + - ) : null} + + {subtitleParts.length > 0 ? ( + + + + {subtitleParts.join(" · ")} + + + ) : null} + - - + + ); } /* ─── Main screen ────────────────────────────────────────────────────── */ +function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { + if (props.catalogState.networkStatus === "offline") { + return "You are offline"; + } + const connectingEnvironments = props.catalogState.connectingEnvironments; + if (connectingEnvironments.length === 1) { + return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; + } + if (connectingEnvironments.length > 1) { + return `Reconnecting ${connectingEnvironments.length} environments`; + } + return "Not connected"; +} + +function StaleCatalogStatusPill(props: { + readonly catalogState: WorkspaceState; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const label = staleCatalogPillLabel(props); + const isReconnecting = props.catalogState.connectingEnvironments.length > 0; + + return ( + + {isReconnecting ? ( + + ) : ( + + )} + + {label} + + + ); +} + export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const openSwipeableRef = useRef(null); + const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const toggleExpanded = useCallback((key: string) => { @@ -306,122 +451,170 @@ export function HomeScreen(props: HomeScreenProps) { }); }, []); - /* Build project title lookup for search */ - const projectTitleByKey = useMemo(() => { - const map = new Map(); - for (const p of props.projects) { - map.set(scopedProjectKey(p.environmentId, p.id), p.title); - } - return map; - }, [props.projects]); - - /* Filter threads by search query */ - const filteredThreads = useMemo(() => { - const q = props.searchQuery.trim().toLowerCase(); - if (!q) return props.threads; - return props.threads.filter((t) => { - if (t.title.toLowerCase().includes(q)) return true; - const key = scopedProjectKey(t.environmentId, t.projectId); - return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false; - }); - }, [props.threads, props.searchQuery, projectTitleByKey]); - - /* Group filtered threads by project */ - const projectGroups = useMemo>(() => { - const byProject = new Map(); - for (const thread of filteredThreads) { - const key = scopedProjectKey(thread.environmentId, thread.projectId); - const existing = byProject.get(key); - if (existing) existing.push(thread); - else byProject.set(key, [thread]); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current !== methods) { + openSwipeableRef.current?.close(); + openSwipeableRef.current = methods; } + }, []); - const groups: ProjectGroup[] = []; - for (const project of props.projects) { - const key = scopedProjectKey(project.environmentId, project.id); - const threads = byProject.get(key); - if (threads && threads.length > 0) { - groups.push({ key, project, threads }); - } + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; } + }, []); - return Arr.sort(groups, projectGroupActivityOrder); - }, [props.projects, filteredThreads]); + const projectGroups = useMemo( + () => + buildHomeThreadGroups({ + projects: props.projects, + threads: props.threads, + environmentId: props.selectedEnvironmentId, + searchQuery: props.searchQuery, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + projectGroupingMode: props.projectGroupingMode, + }), + [ + props.projectGroupingMode, + props.projects, + props.projectSortOrder, + props.searchQuery, + props.selectedEnvironmentId, + props.threadSortOrder, + props.threads, + ], + ); /* Empty states */ - const hasAnyThreads = props.threads.length > 0; - const hasResults = filteredThreads.length > 0; + const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); + const hasResults = projectGroups.length > 0; + const selectedEnvironmentLabel = + props.selectedEnvironmentId === null + ? null + : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? + "this environment"); + const hasSearchQuery = props.searchQuery.trim().length > 0; + const shouldShowConnectionStatus = + props.catalogState.networkStatus === "offline" || + props.catalogState.hasConnectingEnvironment || + (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); return ( - - {!hasAnyThreads ? ( - + + openSwipeableRef.current?.close()} + className="flex-1" + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 24, + gap: 20, + }} + > + {!hasAnyThreads ? ( + + + {emptyState.loading ? ( + + + + ) : null} + + ) : !hasResults && hasSearchQuery ? ( + + ) : !hasResults && selectedEnvironmentLabel ? ( - {emptyState.loading ? ( - - - - ) : null} - - ) : !hasResults ? ( - - ) : ( - projectGroups.map((group) => { - const connection = props.savedConnectionsById[group.project.environmentId]; - const isExpanded = expandedProjects.has(group.key); - const visibleThreads = isExpanded - ? group.threads - : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); - - return ( - - toggleExpanded(group.key)} - /> - + ) : ( + projectGroups.map((group) => { + const isExpanded = expandedProjects.has(group.key); + const visibleThreads = isExpanded + ? group.threads + : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + + return ( + - {visibleThreads.map((thread, i) => ( - props.onSelectThread(thread)} - isLast={i === visibleThreads.length - 1} - /> - ))} - - - ); - }) - )} - + toggleExpanded(group.key)} + project={group.representative} + title={group.title} + totalThreadCount={group.threads.length} + /> + + {visibleThreads.map((thread, i) => { + const threadKey = `${thread.environmentId}:${thread.id}`; + return ( + + props.onArchiveThread(thread)} + onDelete={() => props.onDeleteThread(thread)} + onPress={() => props.onSelectThread(thread)} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + + ); + })} + + + ); + }) + )} + + {shouldShowConnectionStatus ? ( + + + + ) : null} + ); } diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts new file mode 100644 index 00000000000..cf9b0824aa4 --- /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 00000000000..9f09e894c20 --- /dev/null +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -0,0 +1,140 @@ +import { + deriveLogicalProjectKey, + deriveProjectGroupLabel, +} from "@t3tools/client-runtime/state/project-grouping"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import type { + EnvironmentId, + SidebarProjectGroupingMode, + SidebarProjectSortOrder, + SidebarThreadSortOrder, +} from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; + +export type HomeProjectSortOrder = Exclude; + +export interface HomeThreadGroup { + readonly key: string; + readonly title: string; + readonly representative: EnvironmentProject; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +} + +interface MutableHomeThreadGroup { + readonly key: string; + readonly projects: EnvironmentProject[]; + readonly threads: EnvironmentThreadShell[]; +} + +function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { + return group.threads.reduce( + (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), + Number.NEGATIVE_INFINITY, + ); +} + +export function buildHomeThreadGroups(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly projectGroupingMode: SidebarProjectGroupingMode; +}): ReadonlyArray { + const groups = new Map(); + const groupKeyByProjectKey = new Map(); + + for (const project of input.projects) { + if (input.environmentId !== null && project.environmentId !== input.environmentId) { + continue; + } + + const groupKey = deriveLogicalProjectKey(project, { + groupingMode: input.projectGroupingMode, + }); + const physicalKey = scopedProjectKey(project.environmentId, project.id); + groupKeyByProjectKey.set(physicalKey, groupKey); + + const existing = groups.get(groupKey); + if (existing) { + existing.projects.push(project); + } else { + groups.set(groupKey, { key: groupKey, projects: [project], threads: [] }); + } + } + + for (const thread of input.threads) { + if (thread.archivedAt !== null) { + continue; + } + if (input.environmentId !== null && thread.environmentId !== input.environmentId) { + continue; + } + + const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); + const groupKey = groupKeyByProjectKey.get(physicalKey); + if (!groupKey) { + continue; + } + groups.get(groupKey)?.threads.push(thread); + } + + const query = input.searchQuery.trim().toLocaleLowerCase(); + const result: HomeThreadGroup[] = []; + + for (const group of groups.values()) { + const representative = group.projects[0]; + if (!representative || group.threads.length === 0) { + continue; + } + + const title = + group.projects.length > 1 + ? deriveProjectGroupLabel({ representative, members: group.projects }) + : representative.title; + const groupMatches = + query.length === 0 || + title.toLocaleLowerCase().includes(query) || + group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); + const matchingThreads = groupMatches + ? group.threads + : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + + if (matchingThreads.length === 0) { + continue; + } + + result.push({ + key: group.key, + title, + representative, + projects: group.projects, + threads: sortThreads(matchingThreads, input.threadSortOrder), + }); + } + + return Arr.sort( + result, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + title: Order.String, + key: Order.String, + }), + (group: HomeThreadGroup) => ({ + timestamp: groupSortTimestamp(group, input.projectSortOrder), + title: group.title, + key: group.key, + }), + ), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx new file mode 100644 index 00000000000..dd0e2901bba --- /dev/null +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -0,0 +1,238 @@ +import { SymbolView } from "expo-symbols"; +import type { ComponentProps } from "react"; +import type { ColorValue } from "react-native"; +import { Pressable, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { + Extrapolation, + interpolate, + runOnJS, + type SharedValue, + useAnimatedReaction, + useAnimatedStyle, +} from "react-native-reanimated"; + +import { AppText as Text } from "../../components/AppText"; + +const ACTION_ITEM_WIDTH = 50; +const ACTION_CIRCLE_SIZE = 36; +const ACTION_ICON_SIZE = 15; + +export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; +export const THREAD_SWIPE_SPRING = { + damping: 26, + mass: 0.7, + overshootClamping: true, + stiffness: 330, +}; + +function SwipeActionButton(props: { + readonly accessibilityLabel: string; + readonly backgroundColor: string; + readonly entryRange: readonly [number, number]; + readonly fullSwipeThreshold: number; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; + readonly stretchesOnFullSwipe: boolean; + readonly translation: SharedValue; +}) { + const actionStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); + const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const fullSwipeProgress = interpolate( + reveal, + [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], + [0, 1], + Extrapolation.CLAMP, + ); + + return { + opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), + transform: [ + { + translateX: + interpolate(entryProgress, [0, 1], [22, 0]) - + (props.stretchesOnFullSwipe ? 0 : stretch), + }, + { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, + ], + }; + }); + const circleStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const stretch = props.stretchesOnFullSwipe + ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) + : 0; + + return { + transform: [{ translateX: -stretch }], + width: ACTION_CIRCLE_SIZE + stretch, + }; + }); + const iconStyle = useAnimatedStyle(() => { + const reveal = Math.max(-props.translation.value, 0); + const stretch = props.stretchesOnFullSwipe + ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) + : 0; + const armedProgress = interpolate( + reveal, + [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], + [0, 1], + Extrapolation.CLAMP, + ); + + return { + transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }], + }; + }); + const labelStyle = useAnimatedStyle(() => { + if (!props.stretchesOnFullSwipe) { + return { opacity: 1 }; + } + + const reveal = Math.max(-props.translation.value, 0); + const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + return { + opacity: interpolate( + reveal, + [props.fullSwipeThreshold - 24, props.fullSwipeThreshold], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [{ translateX: -stretch * 0.5 }], + }; + }); + + return ( + + ({ + alignItems: "center", + height: "100%", + justifyContent: "center", + opacity: pressed ? 0.72 : 1, + width: "100%", + })} + > + + + + + + + + {props.label} + + + + ); +} + +export function ThreadSwipeActions(props: { + readonly backgroundColor: ColorValue; + readonly fullSwipeThreshold: number; + readonly onDelete: () => void; + readonly onFullSwipeArmedChange: (armed: boolean) => void; + readonly primaryAction: { + readonly accessibilityLabel: string; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; + }; + readonly swipeableMethods: SwipeableMethods; + readonly threadTitle: string; + readonly translation: SharedValue; +}) { + useAnimatedReaction( + () => -props.translation.value >= props.fullSwipeThreshold, + (armed, previous) => { + if (armed !== previous) { + runOnJS(props.onFullSwipeArmedChange)(armed); + } + }, + [props.fullSwipeThreshold, props.onFullSwipeArmedChange], + ); + + return ( + + + { + props.swipeableMethods.close(); + props.onDelete(); + }} + stretchesOnFullSwipe + translation={props.translation} + /> + + ); +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts new file mode 100644 index 00000000000..cc5d0dd047f --- /dev/null +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -0,0 +1,142 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import * as Cause from "effect/Cause"; +import * as Haptics from "expo-haptics"; +import { useCallback, useRef } from "react"; +import { Alert } from "react-native"; + +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +type ThreadListAction = "archive" | "unarchive" | "delete"; + +function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { + const error = Cause.squash(cause); + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + const verb = + action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; + return `The thread could not be ${verb}.`; +} + +function selectionHaptic(): void { + if (process.env.EXPO_OS === "ios") { + void Haptics.selectionAsync(); + } +} + +function actionFailureTitle(action: ThreadListAction): string { + if (action === "archive") return "Could not archive thread"; + if (action === "unarchive") return "Could not unarchive thread"; + return "Could not delete thread"; +} + +function useThreadActionExecutor( + onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, +) { + const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); + const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); + const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const inFlightThreadKeys = useRef(new Set()); + + const executeAction = useCallback( + async (action: ThreadListAction, thread: EnvironmentThreadShell) => { + const key = scopedThreadKey(thread.environmentId, thread.id); + if (inFlightThreadKeys.current.has(key)) { + return; + } + + inFlightThreadKeys.current.add(key); + selectionHaptic(); + try { + const mutation = + action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation; + const result = await mutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); + return; + } + onCompleted?.(action, thread); + } finally { + inFlightThreadKeys.current.delete(key); + } + }, + [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + ); + + return executeAction; +} + +function useConfirmDeleteThread( + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, +) { + return useCallback( + (thread: EnvironmentThreadShell) => { + Alert.alert( + "Delete thread?", + `“${thread.title}” will be permanently deleted, including its terminal history.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void executeAction("delete", thread); + }, + }, + ], + ); + }, + [executeAction], + ); +} + +export function useThreadListActions(): { + readonly archiveThread: (thread: EnvironmentThreadShell) => void; + readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; +} { + const executeAction = useThreadActionExecutor(); + + const archiveThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("archive", thread); + }, + [executeAction], + ); + + const confirmDeleteThread = useConfirmDeleteThread(executeAction); + + return { archiveThread, confirmDeleteThread }; +} + +export function useArchivedThreadListActions( + onCompleted: (thread: EnvironmentThreadShell) => void, +): { + readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; + readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; +} { + const handleCompleted = useCallback( + (_action: ThreadListAction, thread: EnvironmentThreadShell) => { + onCompleted(thread); + }, + [onCompleted], + ); + const executeAction = useThreadActionExecutor(handleCompleted); + const unarchiveThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("unarchive", thread); + }, + [executeAction], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); + + return { unarchiveThread, confirmDeleteThread }; +} diff --git a/apps/mobile/src/features/observability/mobileTracing.test.ts b/apps/mobile/src/features/observability/mobileTracing.test.ts deleted file mode 100644 index 0b0f83c6971..00000000000 --- a/apps/mobile/src/features/observability/mobileTracing.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; - -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; - -import { makeMobileTracingLayer } from "./mobileTracing"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: {}, - }, - }, -})); - -it.effect("exports spans through the scoped mobile OTLP layer", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const tracingLayer = makeMobileTracingLayer( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - { - appVariant: "test", - serviceVersion: "1.2.3", - }, - ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); - const tracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), - ).pipe(Layer.provide(tracingLayer)); - - return Effect.gen(function* () { - yield* Layer.build(tracedApplication); - - expect(fetchFn).not.toHaveBeenCalled(); - }).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - const [url, init] = fetchFn.mock.calls[0]!; - expect(String(url)).toBe("https://api.axiom.test/v1/traces"); - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); - expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); - expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); - }), - ), - ); -}); diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts new file mode 100644 index 00000000000..b0deb15be8c --- /dev/null +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; + +import { makeTracingLayer } from "./tracing"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: {}, + }, + }, +})); + +it.effect("exports spans through the scoped mobile OTLP layer", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const tracedApplication = Layer.effectDiscard( + Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), + ).pipe(Layer.provide(tracingLayer)); + + return Effect.gen(function* () { + yield* Layer.build(tracedApplication); + + expect(fetchFn).not.toHaveBeenCalled(); + }).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const [url, init] = fetchFn.mock.calls[0]!; + expect(String(url)).toBe("https://api.axiom.test/v1/traces"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); + expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); + expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); + }), + ), + ); +}); + +it.effect("does not let OTLP serialization failures alter application effects", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const failure = { durationNanos: 1n }; + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("mobile.test.failed-span"), + withRelayClientTracing, + Effect.exit, + Effect.flatMap((exit) => { + const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined; + return reason && Cause.isFailReason(reason) + ? Effect.sync(() => { + expect(reason.error).toBe(failure); + }) + : Effect.die(new Error("Expected the original typed failure.")); + }), + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain( + "mobile.test.failed-span", + ); + }), + ), + ); +}); diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/tracing.ts similarity index 64% rename from apps/mobile/src/features/observability/mobileTracing.ts rename to apps/mobile/src/features/observability/tracing.ts index dfc6f875c1b..eb73abba292 100644 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,32 +1,29 @@ import Constants from "expo-constants"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; -export interface MobileTracingConfig { +export interface TracingConfig { readonly tracesUrl: string; readonly tracesDataset: string; readonly tracesToken: string; } -export interface MobileTracingResource { +export interface TracingResource { readonly serviceVersion?: string; readonly appVariant: string; } -export function resolveMobileTracingConfig(): MobileTracingConfig | null { +export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); - if (!hasMobileTracingPublicConfig(config)) { + if (!hasTracingPublicConfig(config)) { return null; } const { tracesUrl, tracesDataset, tracesToken } = config.observability; return { tracesUrl, tracesDataset, tracesToken }; } -export function makeMobileTracingLayer( - config: MobileTracingConfig | null, - resource: MobileTracingResource, -) { +export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { serviceName: "t3-mobile-relay-client", serviceVersion: resource.serviceVersion, @@ -35,7 +32,7 @@ export function makeMobileTracingLayer( }); } -export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { +export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a7423966f67..fa1f635de8d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -2,23 +2,25 @@ import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, - appendBrowsePathSegment, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, - canNavigateUp, - ensureBrowseDirectoryPath, findExistingAddProject, getAddProjectInitialQuery, + resolveAddProjectPath, + sortAddProjectProviderSources, + type AddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, getBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isFilesystemBrowseQuery, - resolveAddProjectPath, - sortAddProjectProviderSources, - type AddProjectRemoteSource, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; @@ -26,21 +28,23 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; +import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useProjects, useServerConfigs } from "../../state/entities"; +import { filesystemEnvironment } from "../../state/filesystem"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; -import { - refreshSourceControlDiscoveryForEnvironment, - useSourceControlDiscovery, -} from "../../state/use-source-control-discovery"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -96,7 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( {props.children} @@ -164,9 +168,9 @@ function ListRow(props: { {props.icon} - {props.title} + {props.title} {props.subtitle ? ( - + {props.subtitle} ) : null} @@ -198,7 +202,7 @@ function PrimaryActionButton(props: { {props.loading ? ( ) : ( - {props.label} + {props.label} )} ); @@ -211,7 +215,7 @@ function ProjectPathInput(props: { }) { return ( { - const { serverConfigByEnvironmentId } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const serverConfigByEnvironmentId = useServerConfigs(); + const { savedConnectionsById } = useSavedRemoteConnections(); return useMemo>(() => { const options = Object.values(savedConnectionsById).map((connection) => { - const config = serverConfigByEnvironmentId[connection.environmentId]; + const config = serverConfigByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, @@ -269,15 +273,15 @@ function EmptyEnvironmentState() { return ( - No environments connected - + No environments connected + Add an environment before adding a project. router.replace("/connections/new")} className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" > - Add environment + Add environment ); @@ -336,17 +340,19 @@ export function AddProjectSourceScreen() { const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); - const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); + const discoveryState = useEnvironmentQuery( + selectedEnvironment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedEnvironment.environmentId, + input: {}, + }), + ); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); - useEffect(() => { - if (!selectedEnvironment) return; - void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); - }, [selectedEnvironment]); - return ( {environmentOptions.length === 0 ? : null} @@ -435,13 +441,12 @@ export function AddProjectSourceScreen() { function useCreateProject(environment: EnvironmentOption | null) { const router = useRouter(); - const { projects } = useRemoteCatalog(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const projects = useProjects(); return useCallback( async (workspaceRoot: string) => { if (!environment) return; - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); const existing = findExistingAddProject({ projects, @@ -462,14 +467,19 @@ function useCreateProject(environment: EnvironmentOption | null) { } const projectId = ProjectId.make(uuidv4()); - await client.orchestration.dispatchCommand( - buildProjectCreateCommand({ - commandId: CommandId.make(uuidv4()), - projectId, - workspaceRoot, - createdAt: new Date().toISOString(), - }), - ); + const command = buildProjectCreateCommand({ + commandId: CommandId.make(uuidv4()), + projectId, + workspaceRoot, + createdAt: new Date().toISOString(), + }); + const result = await createProject({ + environmentId: environment.environmentId, + input: command, + }); + if (AsyncResult.isFailure(result)) { + return result; + } router.replace({ pathname: "/new/draft", params: { @@ -478,8 +488,9 @@ function useCreateProject(environment: EnvironmentOption | null) { title: inferProjectTitleFromPath(workspaceRoot), }, }); + return result; }, - [environment, projects, router], + [createProject, environment, projects, router], ); } @@ -495,6 +506,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null { } export function AddProjectRepositoryScreen() { + const lookupRepositoryQuery = useAtomQueryRunner(sourceControlEnvironment.repository, { + reportFailure: false, + }); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); const environment = useEnvironmentFromParam(); @@ -507,28 +521,33 @@ export function AddProjectRepositoryScreen() { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; setError(null); setIsSubmitting(true); - try { - const provider = addProjectRemoteSourceProvider(source); - if (!provider) { - const remoteUrl = repositoryInput.trim(); - router.push({ - pathname: "/new/add-project/destination", - params: { - environmentId: environment.environmentId, - source, - remoteUrl, - repositoryTitle: remoteUrl, - }, - }); - return; - } + const provider = addProjectRemoteSourceProvider(source); + if (!provider) { + const remoteUrl = repositoryInput.trim(); + router.push({ + pathname: "/new/add-project/destination", + params: { + environmentId: environment.environmentId, + source, + remoteUrl, + repositoryTitle: remoteUrl, + }, + }); + setIsSubmitting(false); + return; + } - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const repository = await client.sourceControl.lookupRepository({ + const result = await lookupRepositoryQuery({ + environmentId: environment.environmentId, + input: { provider, repository: repositoryInput.trim(), - }); + }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + } else { + const repository = result.value; router.push({ pathname: "/new/add-project/destination", params: { @@ -538,18 +557,15 @@ export function AddProjectRepositoryScreen() { repositoryTitle: repository.nameWithOwner, }, }); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); } - }, [environment, isSubmitting, repositoryInput, router, source]); + setIsSubmitting(false); + }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, router, source]); return ( {error ? : null} (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), [browseDirectoryPath], ); - const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); + const browseState = useEnvironmentQuery( + browseInput === null + ? null + : filesystemEnvironment.browse({ + environmentId: props.environment.environmentId, + input: browseInput, + }), + ); const visibleBrowseEntries = useMemo( () => Arr.sort( @@ -686,13 +709,11 @@ export function AddProjectLocalFolderScreen() { } setIsSubmitting(true); - try { - await createProject(resolved.path); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); + const result = await createProject(resolved.path); + if (result && AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); } + setIsSubmitting(false); }, [createProject, environment, isSubmitting, pathInput]); return ( @@ -725,6 +746,9 @@ export function AddProjectLocalFolderScreen() { } export function AddProjectDestinationScreen() { + const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + reportFailure: false, + }); const params = useLocalSearchParams<{ environmentId?: string; remoteUrl?: string; @@ -759,28 +783,31 @@ export function AddProjectDestinationScreen() { } setIsSubmitting(true); - try { - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const result = await client.sourceControl.cloneRepository({ + const cloneResult = await cloneRepository({ + environmentId: environment.environmentId, + input: { remoteUrl, destinationPath: resolved.path, - }); - await createProject(result.cwd); - } catch (nextError) { - setError(errorMessage(nextError)); - } finally { - setIsSubmitting(false); + }, + }); + if (AsyncResult.isFailure(cloneResult)) { + setError(errorMessage(Cause.squash(cloneResult.cause))); + } else { + const createResult = await createProject(cloneResult.value.cwd); + if (createResult && AsyncResult.isFailure(createResult)) { + setError(errorMessage(Cause.squash(createResult.cause))); + } } - }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); + setIsSubmitting(false); + }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); return ( {error ? : null} {repositoryTitle ? ( - {repositoryTitle} - + {repositoryTitle} + {remoteUrl} diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 65255c14ff3..d35c48e8a9b 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -159,26 +159,26 @@ export function ReviewCommentComposerSheet() { - Add Comment + Add Comment {!target ? ( - No selection - + No selection + Select a diff line or range first. ) : ( - + {selectionLabel} @@ -215,7 +215,7 @@ export function ReviewCommentComposerSheet() { > {lineNumber ?? ""} @@ -236,7 +236,7 @@ export function ReviewCommentComposerSheet() { - Comment + Comment @@ -248,7 +248,7 @@ export function ReviewCommentComposerSheet() { textAlignVertical="top" value={commentText} onChangeText={setCommentText} - className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" + className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" style={{ flex: 1, minHeight: 0 }} /> diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index c82ca71596a..92203c0ed4e 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -16,8 +16,13 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useReviewCacheForThread } from "./reviewState"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { @@ -29,6 +34,7 @@ import { useReviewFileVisibility } from "./reviewFileVisibility"; import { useReviewSections } from "./useReviewSections"; import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; +import { resolveReviewAvailability } from "./reviewAvailability"; const IOS_NAV_BAR_HEIGHT = 44; const REVIEW_HEADER_SPACING = 0; @@ -36,10 +42,10 @@ const REVIEW_HEADER_SPACING = 0; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff - + {props.notice} @@ -64,7 +70,7 @@ function ReviewSelectionActionBar(props: { tintColor="#ffffff" type="monochrome" /> - {props.title} + {props.title} ); @@ -114,6 +120,9 @@ export function ReviewSheet() { environmentId: EnvironmentId; threadId: ThreadId; }>(); + const environment = useEnvironmentPresentation(environmentId); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; @@ -126,7 +135,12 @@ export function ReviewSheet() { selectedSection, refreshSelectedSection, selectSection, - } = useReviewSections({ environmentId, threadId, reviewCache }); + } = useReviewSections({ + enabled: isEnvironmentReady, + environmentId, + threadId, + reviewCache, + }); const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = useReviewDiffData({ threadKey: reviewCache.threadKey, @@ -187,6 +201,17 @@ export function ReviewSheet() { const parsedDiffNotice = parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; + const hasCachedSelectedDiff = selectedSection?.diff != null; + const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); + const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ + hasEnvironmentPresentation: environment.isReady, + isEnvironmentConnected: isEnvironmentReady, + hasCachedSelectedDiff, + hasAnyCachedDiff, + }); + const handleRetryEnvironment = useCallback(() => { + void retryEnvironment(environmentId); + }, [environmentId, retryEnvironment]); const listHeader = useMemo(() => { const children: ReactElement[] = []; @@ -194,8 +219,8 @@ export function ReviewSheet() { if (error) { children.push( - Review unavailable - {error} + Review unavailable + {error} , ); } @@ -227,7 +252,7 @@ export function ReviewSheet() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", color: headerForeground, letterSpacing: -0.4, @@ -249,7 +274,7 @@ export function ReviewSheet() { - - - {reviewSections.map((section) => ( + {showSectionToolbar ? ( + + + {reviewSections.map((section) => ( + selectSection(section.id)} + subtitle={section.subtitle ?? undefined} + > + {section.title} + + ))} selectSection(section.id)} - subtitle={section.subtitle ?? undefined} + icon="arrow.clockwise" + disabled={ + loadingGitDiffs || + (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) + } + onPress={() => void refreshSelectedSection()} + subtitle="Reload current diff" > - {section.title} + Refresh - ))} - void refreshSelectedSection()} - subtitle="Reload current diff" - > - Refresh - - - + + + ) : null} - {selectedSection && parsedDiff.kind === "files" ? ( + {showConnectionNotice ? ( + + + + ) : selectedSection && parsedDiff.kind === "files" ? ( - No review diffs - + No review diffs + This thread has no ready turn diffs and the worktree diff is empty. ) : selectedSection.isLoading && selectedSection.diff === null ? ( - Loading diff… + Loading diff… ) : parsedDiff.kind === "empty" ? ( - No changes - + No changes + {selectedSection.subtitle ?? "This diff is empty."} ) : parsedDiff.kind === "raw" ? ( - + {parsedDiff.reason} - + {parsedDiff.text} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index d747dfc531b..f60fdfe70e0 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -5,6 +5,7 @@ import type { } from "../diffs/nativeReviewDiffTypes"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; import { @@ -18,16 +19,16 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = 20; +export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; export const NATIVE_REVIEW_DIFF_STYLE = { rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, - gutterWidth: 46, - codePadding: 7, - textVerticalInset: 2, + gutterWidth: MOBILE_CODE_SURFACE.gutterWidth, + codePadding: MOBILE_CODE_SURFACE.codePadding, + textVerticalInset: MOBILE_CODE_SURFACE.textVerticalInset, fileHeaderHeight: 56, fileHeaderHorizontalMargin: 8, fileHeaderVerticalMargin: 6, @@ -36,9 +37,9 @@ export const NATIVE_REVIEW_DIFF_STYLE = { fileHeaderPathRightPadding: 118, fileHeaderCountColumnWidth: 38, fileHeaderCountGap: 5, - codeFontSize: 11, + codeFontSize: MOBILE_CODE_SURFACE.fontSize, codeFontWeight: "regular", - lineNumberFontSize: 10, + lineNumberFontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, lineNumberFontWeight: "regular", hunkFontSize: 11, hunkFontWeight: "medium", diff --git a/apps/mobile/src/features/review/reviewAvailability.test.ts b/apps/mobile/src/features/review/reviewAvailability.test.ts new file mode 100644 index 00000000000..bd25d47a7af --- /dev/null +++ b/apps/mobile/src/features/review/reviewAvailability.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveReviewAvailability } from "./reviewAvailability"; + +describe("resolveReviewAvailability", () => { + it("keeps section navigation available when another section is cached offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: false, + hasAnyCachedDiff: true, + }), + ).toEqual({ + showConnectionNotice: true, + showSectionToolbar: true, + }); + }); + + it("hides section navigation when no review section is available offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: false, + hasAnyCachedDiff: false, + }), + ).toEqual({ + showConnectionNotice: true, + showSectionToolbar: false, + }); + }); + + it("shows cached selected content and navigation while offline", () => { + expect( + resolveReviewAvailability({ + hasEnvironmentPresentation: true, + isEnvironmentConnected: false, + hasCachedSelectedDiff: true, + hasAnyCachedDiff: true, + }), + ).toEqual({ + showConnectionNotice: false, + showSectionToolbar: true, + }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewAvailability.ts b/apps/mobile/src/features/review/reviewAvailability.ts new file mode 100644 index 00000000000..5e6b1da9bb7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewAvailability.ts @@ -0,0 +1,19 @@ +export function resolveReviewAvailability(input: { + readonly hasEnvironmentPresentation: boolean; + readonly isEnvironmentConnected: boolean; + readonly hasCachedSelectedDiff: boolean; + readonly hasAnyCachedDiff: boolean; +}): { + readonly showConnectionNotice: boolean; + readonly showSectionToolbar: boolean; +} { + const showConnectionNotice = + input.hasEnvironmentPresentation && + !input.isEnvironmentConnected && + !input.hasCachedSelectedDiff; + + return { + showConnectionNotice, + showSectionToolbar: !showConnectionNotice || input.hasAnyCachedDiff, + }; +} diff --git a/apps/mobile/src/features/review/reviewDiffPreviewState.ts b/apps/mobile/src/features/review/reviewDiffPreviewState.ts deleted file mode 100644 index d0f85cd6d89..00000000000 --- a/apps/mobile/src/features/review/reviewDiffPreviewState.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; - -import { appAtomRegistry } from "../../state/atom-registry"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; - -const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000; -const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000; -const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f"; - -export interface ReviewDiffPreviewState { - readonly data: ReviewDiffPreviewResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function makeReviewDiffPreviewKey(input: { - readonly environmentId: EnvironmentId; - readonly cwd: string; -}): string { - return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`; -} - -function parseReviewDiffPreviewKey(key: string): { - readonly environmentId: EnvironmentId; - readonly cwd: string; -} { - const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR); - return { - environmentId: environmentId as EnvironmentId, - cwd, - }; -} - -const reviewDiffPreviewAtom = Atom.family((key: string) => - Atom.make( - Effect.promise(async (): Promise => { - const target = parseReviewDiffPreviewKey(key); - const client = getEnvironmentClient(target.environmentId); - if (!client) { - throw new Error("Remote connection is not ready."); - } - return client.review.getDiffPreview({ cwd: target.cwd }); - }), - ).pipe( - Atom.swr({ - staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS), - Atom.withLabel(`mobile:review:diff-preview:${key}`), - ), -); - -const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make( - AsyncResult.initial(false), -).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null")); - -function readReviewDiffPreviewError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const error = Cause.squash(result.cause); - return error instanceof Error ? error.message : "Failed to load review diffs."; -} - -export function useReviewDiffPreview(input: { - readonly environmentId?: EnvironmentId; - readonly cwd: string | null; -}): ReviewDiffPreviewState { - const key = useMemo(() => { - if (!input.environmentId || !input.cwd) { - return null; - } - return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd }); - }, [input.cwd, input.environmentId]); - - const atom = key ? reviewDiffPreviewAtom(key) : null; - const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM); - const refresh = useCallback(() => { - if (atom) { - appAtomRegistry.refresh(atom); - } - }, [atom]); - - if (!atom) { - return { - data: null, - error: null, - isPending: false, - refresh, - }; - } - - return { - data: Option.getOrNull(AsyncResult.value(result)), - error: readReviewDiffPreviewError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 3f2ae01609e..14ff0276657 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -1,6 +1,7 @@ import { Platform, Text as NativeText, View } from "react-native"; import { cn } from "../../lib/cn"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; @@ -11,7 +12,7 @@ export const REVIEW_MONO_FONT_FAMILY = Platform.select({ default: "monospace", }); -export const REVIEW_DIFF_LINE_HEIGHT = 26; +export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2; export function renderVisibleWhitespace(value: string): string { @@ -71,8 +72,12 @@ export function DiffTokenText(props: { {renderVisibleWhitespace(props.fallback || " ")} @@ -83,8 +88,12 @@ export function DiffTokenText(props: { {(() => { let offset = 0; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index d6d09221dac..7030ac77e5f 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -695,6 +695,15 @@ export async function highlightCodeSnippet(input: { return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } +export async function highlightSourceFile(input: { + readonly path: string; + readonly contents: string; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const language = await resolveLanguageFromPath(input.path); + return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); +} + async function highlightPatchLinesInChunks(input: { readonly lines: ReadonlyArray; readonly language: string; diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4c5a1abffb1..87325490990 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useCheckpointDiff } from "../../state/queries"; +import { useEnvironmentQuery } from "../../state/query"; +import { reviewEnvironment } from "../../state/review"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; -import { useReviewDiffPreview } from "./reviewDiffPreviewState"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, getDefaultReviewSectionId, @@ -17,29 +17,30 @@ import { setReviewAsyncError, setReviewGitSections, setReviewSelectedSectionId, - setReviewTurnDiffLoading, setReviewTurnDiff, + setReviewTurnDiffLoading, type ReviewCacheForThread, } from "./reviewState"; export function useReviewSections(input: { + readonly enabled?: boolean; readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; readonly reviewCache: ReviewCacheForThread; }) { const { environmentId, reviewCache, threadId } = input; + const enabled = input.enabled ?? true; const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); - const refreshDiffPreview = diffPreview.refresh; + const diffPreview = useEnvironmentQuery( + enabled && environmentId !== undefined && selectedThreadCwd !== null + ? reviewEnvironment.diffPreview({ + environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const { loadingTurnIds } = reviewCache.asyncState; - const error = diffPreview.error ?? reviewCache.asyncState.error; - const loadingGitDiffs = diffPreview.isPending; - const turnDiffByIdRef = useRef(reviewCache.turnDiffById); - - useEffect(() => { - turnDiffByIdRef.current = reviewCache.turnDiffById; - }, [reviewCache.turnDiffById]); useEffect(() => { if (reviewCache.threadKey && diffPreview.data) { @@ -51,14 +52,16 @@ export function useReviewSections(input: { () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), [selectedThread?.checkpoints], ); - const checkpointBySectionId = useMemo(() => { - return Object.fromEntries( - readyCheckpoints.map((checkpoint) => [ - getReviewSectionIdForCheckpoint(checkpoint), - checkpoint, - ]), - ) as Record; - }, [readyCheckpoints]); + const checkpointBySectionId = useMemo( + () => + Object.fromEntries( + readyCheckpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + checkpoint, + ]), + ) as Record, + [readyCheckpoints], + ); const reviewSections = useMemo( () => buildReviewSectionItems({ @@ -87,7 +90,6 @@ export function useReviewSections(input: { () => getDefaultReviewSectionId(reviewSections), [reviewSections], ); - const hasReviewSections = reviewSections.length > 0; const selectedSectionIdExists = useMemo( () => reviewCache.selectedSectionId @@ -96,140 +98,69 @@ export function useReviewSections(input: { [reviewCache.selectedSectionId, reviewSections], ); - const loadTurnDiff = useCallback( - async (checkpoint: OrchestrationCheckpointSummary, force = false) => { - if (!environmentId || !threadId) { - return; - } - - const sectionId = getReviewSectionIdForCheckpoint(checkpoint); - if (reviewCache.threadKey) { - setReviewSelectedSectionId(reviewCache.threadKey, sectionId); - } - - if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { - return; - } - - const target = { - environmentId, - threadId, - fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), - toTurnCount: checkpoint.checkpointTurnCount, - ignoreWhitespace: false, - cacheScope: sectionId, - }; - const cached = checkpointDiffManager.getSnapshot(target).data; - if (!force && cached) { - if (reviewCache.threadKey) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); - } - return; - } - - if (!getEnvironmentClient(environmentId)) { - if (reviewCache.threadKey) { - setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); - } - return; - } - - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); - setReviewAsyncError(reviewCache.threadKey, null); - } - try { - const result = await loadCheckpointDiff(target, { force }); - if (reviewCache.threadKey) { - if (result) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); - } - } - } catch (cause) { - if (reviewCache.threadKey) { - setReviewAsyncError( - reviewCache.threadKey, - cause instanceof Error ? cause.message : "Failed to load turn diff.", - ); - } - } finally { - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); - } - } - }, - [environmentId, reviewCache.threadKey, threadId], - ); - useEffect(() => { - if (!hasReviewSections) { - return; - } - - if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { + if ( + reviewSections.length > 0 && + reviewCache.threadKey && + (!reviewCache.selectedSectionId || !selectedSectionIdExists) + ) { setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); } }, [ fallbackSectionId, - hasReviewSections, reviewCache.selectedSectionId, reviewCache.threadKey, + reviewSections.length, selectedSectionIdExists, ]); - const latestCheckpoint = readyCheckpoints[0] ?? null; - const latestSectionId = latestCheckpoint - ? getReviewSectionIdForCheckpoint(latestCheckpoint) + let activeCheckpoint = readyCheckpoints[0] ?? null; + if (selectedSection?.kind === "turn") { + activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; + } + const activeSectionId = activeCheckpoint + ? getReviewSectionIdForCheckpoint(activeCheckpoint) : null; - const latestTurnDiffLoaded = latestSectionId - ? reviewCache.turnDiffById[latestSectionId] !== undefined - : true; - const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; + const activeTurnDiff = useCheckpointDiff({ + environmentId: enabled ? (environmentId ?? null) : null, + threadId: enabled ? (threadId ?? null) : null, + fromTurnCount: + enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, + toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null, + ignoreWhitespace: false, + }); useEffect(() => { - if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId) { return; } - - void loadTurnDiff(latestCheckpoint); - }, [ - latestCheckpoint, - latestSectionId, - latestTurnDiffLoaded, - latestTurnDiffLoading, - loadTurnDiff, - ]); - - const selectedTurnCheckpoint = - selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; - const selectedTurnDiffMissing = - selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; - const selectedTurnDiffLoading = - selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; + setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); + }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); useEffect(() => { - if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { return; } + setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); + setReviewAsyncError(reviewCache.threadKey, null); + }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); - void loadTurnDiff(selectedTurnDiffMissing); - }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); + useEffect(() => { + if (reviewCache.threadKey && activeTurnDiff.error) { + setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); + } + }, [activeTurnDiff.error, reviewCache.threadKey]); const refreshSelectedSection = useCallback(async () => { - if (!selectedSection) { + if (!enabled) { return; } - - if (selectedSection.kind === "turn") { - const checkpoint = checkpointBySectionId[selectedSection.id]; - if (checkpoint) { - await loadTurnDiff(checkpoint, true); - } + if (selectedSection?.kind === "turn") { + activeTurnDiff.refresh(); return; } - - refreshDiffPreview(); - }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); + diffPreview.refresh(); + }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); const selectSection = useCallback( (sectionId: string) => { @@ -241,8 +172,8 @@ export function useReviewSections(input: { ); return { - error, - loadingGitDiffs, + error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, + loadingGitDiffs: diffPreview.isPending, loadingTurnIds, reviewSections, selectedSection, diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 9d846a5fff2..ad693dcb445 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -11,6 +11,7 @@ import { } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; import { buildGhosttyThemeConfig, @@ -53,7 +54,7 @@ function estimateGridSize(input: { } const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { - const fontSize = props.fontSize ?? 12; + const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); @@ -93,7 +94,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter @@ -140,7 +141,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter color: theme.foreground, flex: 1, fontFamily: "Menlo", - fontSize: 13, + fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, padding: 0, }} onSubmitEditing={(event) => { @@ -165,7 +166,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter style={{ color: theme.foreground, fontFamily: "DMSans_700Bold", - fontSize: 11, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, }} > Ctrl-C @@ -177,7 +178,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter }); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { - const fontSize = props.fontSize ?? 12; + const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const keyboardInputRef = useRef(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 71643336d54..5d0b0547e6e 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -1,18 +1,19 @@ import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import { SymbolView } from "expo-symbols"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { - attachTerminalSession, - useTerminalSession, - useTerminalSessionTarget, -} from "../../state/use-terminal-session"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAttachedTerminalSession } from "../../state/use-terminal-session"; import { TerminalSurface } from "./NativeTerminalSurface"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; -import { terminalDebugLog } from "./terminalDebugLog"; +import { + buildThreadTerminalAttachInput, + type TerminalGridSize, + type ThreadTerminalSubscriptionIdentity, +} from "./threadTerminalPanelModel"; interface ThreadTerminalPanelProps { readonly environmentId: EnvironmentId; @@ -29,108 +30,93 @@ const DEFAULT_TERMINAL_ROWS = 24; export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( props: ThreadTerminalPanelProps, ) { + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const nativeTerminalAvailable = hasNativeTerminalSurface(); const terminalId = DEFAULT_TERMINAL_ID; - const target = useTerminalSessionTarget({ - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); - const terminal = useTerminalSession(target); - const [lastGridSize, setLastGridSize] = useState({ + const lastGridSizeRef = useRef({ cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }); - const lastGridSizeRef = useRef(lastGridSize); - lastGridSizeRef.current = lastGridSize; + const subscriptionIdentity = useMemo( + () => ({ + environmentId: props.environmentId, + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + }), + [props.cwd, props.environmentId, props.threadId, props.worktreePath, terminalId], + ); + const attachInput = useMemo( + () => + props.visible + ? buildThreadTerminalAttachInput(subscriptionIdentity, lastGridSizeRef.current) + : null, + [props.visible, subscriptionIdentity], + ); + const terminal = useAttachedTerminalSession({ + environmentId: props.environmentId, + terminal: attachInput, + }); const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; - useEffect(() => { - if (!props.visible) { - return; - } - - const client = getEnvironmentClient(props.environmentId); - if (!client) { - terminalDebugLog("panel:attach-skip", { - reason: "no-environment-client", + const sendResize = useCallback( + (size: TerminalGridSize) => { + void resizeTerminal({ environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); - return; - } - - terminalDebugLog("panel:attach", { - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); + }, + [props.environmentId, props.threadId, resizeTerminal, terminalId], + ); - return attachTerminalSession({ - environmentId: props.environmentId, - client, - terminal: { - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - cols: lastGridSizeRef.current.cols, - rows: lastGridSizeRef.current.rows, - }, - }); - }, [ - props.cwd, - props.environmentId, - props.threadId, - props.worktreePath, - props.visible, - terminalId, - ]); + useEffect(() => { + if (isRunning) { + sendResize(lastGridSizeRef.current); + } + }, [isRunning, sendResize]); const handleInput = useCallback( (data: string) => { - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + if (!isRunning) { return; } - void client.terminal.write({ - threadId: props.threadId, - terminalId, - data, + void writeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + data, + }, }); }, - [isRunning, props.environmentId, props.threadId, terminalId], + [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], ); const handleResize = useCallback( - (size: { readonly cols: number; readonly rows: number }) => { - if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { + (size: TerminalGridSize) => { + const previousSize = lastGridSizeRef.current; + if (size.cols === previousSize.cols && size.rows === previousSize.rows) { return; } - setLastGridSize(size); - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + lastGridSizeRef.current = size; + if (!isRunning) { return; } - void client.terminal.resize({ - threadId: props.threadId, - terminalId, - cols: size.cols, - rows: size.rows, - }); + sendResize(size); }, - [ - isRunning, - lastGridSize.cols, - lastGridSize.rows, - props.environmentId, - props.threadId, - terminalId, - ], + [isRunning, sendResize], ); if (!props.visible) { @@ -141,16 +127,16 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( - + Terminal - + {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} {terminal.error ? ( - + {terminal.error} ) : null} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e4ac3cc5c8b..8e9a47a58b5 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,10 +1,5 @@ -import { - DEFAULT_TERMINAL_ID, - EnvironmentId, - type TerminalAttachStreamEvent, - ThreadId, -} from "@t3tools/contracts"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "expo-symbols"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,17 +19,20 @@ import { import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceState } from "../../state/workspace"; import { buildThreadTerminalNavigation } from "../../lib/routes"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { - attachTerminalSession, + useAttachedTerminalSession, useKnownTerminalSessions, - useTerminalSession, - useTerminalSessionTarget, } from "../../state/use-terminal-session"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getPierreTerminalTheme } from "./terminalTheme"; import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; @@ -44,11 +42,10 @@ import { getTerminalSurfaceReplayBuffer, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; -import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; import { resolveTerminalOpenLocation, - stagePendingTerminalLaunch, takePendingTerminalLaunch, + type PendingTerminalLaunch, } from "./terminalLaunchContext"; import { basename, @@ -158,8 +155,12 @@ function pickRunningTerminalSessionForBootstrap( export function ThreadTerminalRouteScreen() { const router = useRouter(); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); + const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); + const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { state: workspaceState } = useWorkspaceState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -174,6 +175,8 @@ export function ThreadTerminalRouteScreen() { ? EnvironmentId.make(routeEnvironmentIdRaw) : null; const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; + const environment = useEnvironmentPresentation(routeEnvironmentId); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; const cachedFontSize = getCachedTerminalFontSize(); @@ -189,6 +192,47 @@ export function ThreadTerminalRouteScreen() { environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); + const runningSession = useMemo( + () => pickRunningTerminalSessionForBootstrap(knownSessions), + [knownSessions], + ); + const activeKnownSession = useMemo( + () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, + [knownSessions, terminalId], + ); + const launchTarget = useMemo( + () => + selectedThread + ? { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId, + } + : null, + [selectedThread, terminalId], + ); + const launchTargetKey = launchTarget + ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}` + : null; + const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{ + readonly key: string | null; + readonly launch: PendingTerminalLaunch | null; + }>(() => ({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + })); + const pendingLaunch = + pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null; + const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey; + const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + })); + const initialAttachGridSize = + initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null; const [lastGridSize, setLastGridSize] = useState( cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, @@ -198,11 +242,10 @@ export function ThreadTerminalRouteScreen() { const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); - const hasOpenedRef = useRef(false); const bufferReplayTimerRef = useRef | null>(null); - const attachStreamLogCountRef = useRef(0); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef(null); + const sentInitialInputKeyRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( cachedFontSize !== null, @@ -216,12 +259,78 @@ export function ThreadTerminalRouteScreen() { terminalId, value: null, }); - const target = useTerminalSessionTarget({ + const shouldRedirectToRunningTerminal = + requestedTerminalId === null && + runningSession !== null && + runningSession.target.terminalId !== terminalId; + const launchLocationCandidate = useMemo(() => { + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + return null; + } + if (pendingLaunch) { + return { + cwd: pendingLaunch.cwd, + worktreePath: pendingLaunch.worktreePath, + }; + } + return resolveTerminalOpenLocation({ + terminalLocation: activeKnownSession?.state.summary ?? null, + activeSessionLocation: activeKnownSession?.state.summary ?? null, + workspaceRoot: selectedThreadProject.workspaceRoot, + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, + }); + }, [ + activeKnownSession?.state.summary, + pendingLaunch, + selectedThread, + selectedThreadDetail?.worktreePath, + selectedThreadProject?.workspaceRoot, + ]); + const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ + key: launchTargetKey, + location: launchLocationCandidate, + })); + const launchLocation = + initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; + const terminalAttachInput = useMemo( + () => + selectedThread !== null && + launchLocation !== null && + hasResolvedPendingLaunch && + initialAttachGridSize !== null && + hasResolvedFontPreference && + hasMeasuredSurface && + isEnvironmentReady && + !shouldRedirectToRunningTerminal + ? { + threadId: selectedThread.id, + terminalId, + cwd: launchLocation.cwd, + worktreePath: launchLocation.worktreePath, + cols: initialAttachGridSize.cols, + rows: initialAttachGridSize.rows, + ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), + ...(pendingLaunch ? { restartIfNotRunning: true } : {}), + } + : null, + [ + hasMeasuredSurface, + hasResolvedFontPreference, + hasResolvedPendingLaunch, + initialAttachGridSize, + isEnvironmentReady, + launchLocation, + pendingLaunch, + selectedThread, + shouldRedirectToRunningTerminal, + terminalId, + ], + ); + const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, - threadId: selectedThread?.id ?? null, - terminalId, + terminal: terminalAttachInput, }); - const terminal = useTerminalSession(target); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; @@ -293,23 +402,6 @@ export function ThreadTerminalRouteScreen() { () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [selectedEnvironmentConnection?.environmentLabel], ); - const runningSession = useMemo( - () => pickRunningTerminalSessionForBootstrap(knownSessions), - [knownSessions], - ); - const activeKnownSession = useMemo( - () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, - [knownSessions, terminalId], - ); - - const terminalAttachLaunchHintsRef = useRef({ - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }); - terminalAttachLaunchHintsRef.current = { - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }; const terminalTheme = getPierreTerminalTheme(appearanceScheme); const pendingModifier = @@ -406,145 +498,88 @@ export function ThreadTerminalRouteScreen() { ], ); - const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { - const n = ++attachStreamLogCountRef.current; - if (event.type === "output" && n > 32 && n % 64 !== 0) { + useEffect(() => { + if (pendingLaunchEntry.key === launchTargetKey) { return; } - if (event.type === "snapshot") { - terminalDebugLog("attach:stream", { - n, - type: event.type, - status: event.snapshot.status, - historyLen: event.snapshot.history.length, - cwd: event.snapshot.cwd, - }); + setPendingLaunchEntry({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + }); + }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]); + + useEffect(() => { + if (initialAttachGridEntry.key === launchTargetKey) { return; } - if (event.type === "output") { - terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); + setInitialAttachGridEntry({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + }); + }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); + + useEffect(() => { + if ( + initialLaunchLocationEntry.key === launchTargetKey && + initialLaunchLocationEntry.location !== null + ) { return; } - terminalDebugLog("attach:stream", { n, type: event.type }); - }, []); - - const attachTerminal = useCallback(() => { - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { - terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); - return null; + if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { + return; } + setInitialLaunchLocationEntry({ + key: launchTargetKey, + location: launchLocationCandidate, + }); + }, [ + initialLaunchLocationEntry.key, + initialLaunchLocationEntry.location, + launchLocationCandidate, + launchTargetKey, + ]); - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - terminalDebugLog("attach:abort", { - reason: "no-environment-client", - environmentId: selectedThread.environmentId, - }); - return null; + useEffect(() => { + if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { + return; } + router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); + }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); - const pendingLaunchTarget = { + useEffect(() => { + const initialInput = pendingLaunch?.initialInput; + if ( + !initialInput || + !selectedThread || + terminal.version === 0 || + sentInitialInputKeyRef.current === launchTargetKey + ) { + return; + } + sentInitialInputKeyRef.current = launchTargetKey; + void writeTerminal({ environmentId: selectedThread.environmentId, - threadId: selectedThread.id, - terminalId, - }; - const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); - let initialInputSent = false; - - try { - const launchLocation = pendingLaunch - ? { - cwd: pendingLaunch.cwd, - worktreePath: pendingLaunch.worktreePath, - } - : resolveTerminalOpenLocation({ - terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, - activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, - workspaceRoot: selectedThreadProject.workspaceRoot, - threadShellWorktreePath: selectedThread.worktreePath ?? null, - threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, - }); - - terminalDebugLog("attach:start", { - terminalId, + input: { threadId: selectedThread.id, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - }); - - return attachTerminalSession({ - environmentId: selectedThread.environmentId, - client, - terminal: { - threadId: selectedThread.id, - terminalId, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - env: pendingLaunch?.env, - ...(pendingLaunch ? { restartIfNotRunning: true } : {}), - }, - onEvent: logAttachStreamEvent, - onSnapshot: () => { - if (!pendingLaunch?.initialInput || initialInputSent) { - return; - } - - initialInputSent = true; - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data: pendingLaunch.initialInput, - }); - }, - }); - } catch (error) { - terminalDebugLog("attach:error", { - message: error instanceof Error ? error.message : String(error), - }); - if (pendingLaunch) { - stagePendingTerminalLaunch({ - target: pendingLaunchTarget, - launch: pendingLaunch, - }); - } - - throw error; - } + terminalId, + data: initialInput, + }, + }); }, [ - lastGridSize.cols, - lastGridSize.rows, - logAttachStreamEvent, - selectedThreadDetail?.worktreePath, + launchTargetKey, + pendingLaunch?.initialInput, selectedThread, - selectedThreadProject?.workspaceRoot, + terminal.version, terminalId, + writeTerminal, ]); - const attachTerminalRef = useRef(attachTerminal); - attachTerminalRef.current = attachTerminal; - const selectedThreadRef = useRef(selectedThread); - selectedThreadRef.current = selectedThread; - const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); - selectedThreadProjectBootstrapRef.current = selectedThreadProject; - const runningSessionRef = useRef(runningSession); - runningSessionRef.current = runningSession; - const terminalBootstrapRef = useRef({ - status: terminal.status, - bufferLen: terminal.buffer.length, - }); - terminalBootstrapRef.current = { - status: terminal.status, - bufferLen: terminal.buffer.length, - }; - useEffect(() => { - hasOpenedRef.current = false; - attachStreamLogCountRef.current = 0; firstNonEmptyBufferLoggedRef.current = false; + sentInitialInputKeyRef.current = null; }, [terminalKey]); const clearBufferReplayTimer = useCallback(() => { @@ -638,99 +673,22 @@ export function ThreadTerminalRouteScreen() { }); }, [fontSize, hasResolvedFontPreference]); - // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. - // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when - // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup - // → detach immediately after the first snapshot. - useEffect(() => { - if (!hasResolvedFontPreference || !hasMeasuredSurface) { - return; - } - - const thread = selectedThreadRef.current; - const project = selectedThreadProjectBootstrapRef.current; - const running = runningSessionRef.current; - const termSnap = terminalBootstrapRef.current; - - const bootstrapAction = resolveTerminalRouteBootstrap({ - hasThread: thread !== null, - hasWorkspaceRoot: Boolean(project?.workspaceRoot), - hasOpened: hasOpenedRef.current, - requestedTerminalId, - currentTerminalId: terminalId, - runningTerminalId: running?.target.terminalId ?? null, - currentTerminalStatus: termSnap.status, - // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; - // treating summary as "hydrated" skipped attach while status was running → empty surface. - hasCurrentTerminalHydration: termSnap.bufferLen > 0, - }); - if (bootstrapAction.kind !== "idle") { - terminalDebugLog("bootstrap:action", { - kind: bootstrapAction.kind, - hasOpenedBefore: hasOpenedRef.current, - hasHydration: termSnap.bufferLen > 0, - terminalStatus: termSnap.status, - bufLen: termSnap.bufferLen, - }); - } - if (bootstrapAction.kind === "idle" || !thread) { - return; - } - - if (bootstrapAction.kind === "redirect") { - router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); - return; - } - - hasOpenedRef.current = true; - try { - const detach = attachTerminalRef.current(); - terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); - if (!detach) { - hasOpenedRef.current = false; - return; - } - return () => { - detach(); - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:unsubscribe"); - }; - } catch (error) { - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:attach-threw", { - message: error instanceof Error ? error.message : String(error), - }); - return; - } - }, [ - hasMeasuredSurface, - hasResolvedFontPreference, - requestedTerminalId, - router, - selectedThread?.environmentId, - selectedThread?.id, - selectedThreadProject?.workspaceRoot, - terminalId, - ]); - const writeInput = useCallback( (data: string) => { if (!selectedThread || !isRunning) { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data, + void writeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + data, + }, }); }, - [isRunning, selectedThread, terminalId], + [isRunning, selectedThread, terminalId, writeTerminal], ); const handleInput = useCallback( @@ -782,16 +740,14 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.resize({ - threadId: selectedThread.id, - terminalId, - cols: size.cols, - rows: size.rows, + void resizeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ @@ -802,6 +758,7 @@ export function ThreadTerminalRouteScreen() { readyBufferReplayKey, routeEnvironmentId, routeThreadId, + resizeTerminal, scheduleBufferReplayReady, selectedThread, terminalId, @@ -855,17 +812,15 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - setPendingModifierState({ terminalId, value: null }); - void client.terminal.clear({ - threadId: selectedThread.id, - terminalId, + void clearTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, }); - }, [selectedThread, terminalId]); + }, [clearTerminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { @@ -905,9 +860,14 @@ export function ThreadTerminalRouteScreen() { const handleShowKeyboard = useCallback(() => { setKeyboardFocusRequest((current) => current + 1); }, []); + const handleRetryEnvironment = useCallback(() => { + if (routeEnvironmentId !== null) { + void retryEnvironment(routeEnvironmentId); + } + }, [retryEnvironment, routeEnvironmentId]); if (!selectedThread) { - if (isLoadingSavedConnection) { + if (workspaceState.isLoadingConnections) { return ; } @@ -932,6 +892,10 @@ export function ThreadTerminalRouteScreen() { ); } + if (!environment.isReady && environment.presentation === null) { + return ; + } + return ( <> @@ -969,7 +933,7 @@ export function ThreadTerminalRouteScreen() { style={{ color: terminalTheme.mutedForeground, fontFamily: "Menlo", - fontSize: 11, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, lineHeight: 14, }} > @@ -980,152 +944,178 @@ export function ThreadTerminalRouteScreen() { }} /> - - - - {getTerminalStatusLabel({ - status: terminal.status, - hasRunningSubprocess: terminal.hasRunningSubprocess, - })} - - - Text size - - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - + {isEnvironmentReady ? ( + + + + {getTerminalStatusLabel({ + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + })} + + + Text size + + {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + = MAX_TERMINAL_FONT_SIZE} + discoverabilityLabel="Increase terminal text size" + onPress={handleIncreaseFontSize} + > + {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + + {terminalMenuSessions.map((session) => ( + handleSelectTerminal(session.terminalId)} + subtitle={[ + getTerminalStatusLabel({ status: session.status }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} = MAX_TERMINAL_FONT_SIZE} - discoverabilityLabel="Increase terminal text size" - onPress={handleIncreaseFontSize} + icon="plus" + onPress={handleOpenNewTerminal} + subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} > - {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + Open new terminal - {terminalMenuSessions.map((session) => ( - handleSelectTerminal(session.terminalId)} - subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · ")} - > - {session.displayLabel} - - ))} - - Open new terminal - - - + + ) : null} - - - + ) : ( + <> + + + - {isAccessoryVisible ? ( - - - - + - {terminalToolbarActions.map((action) => { - const active = - action.kind === "modifier" && pendingModifier === action.modifier; - - return ( - 1 ? 56 : 44} - onPress={() => handleToolbarActionPress(action)} - showChevron={false} - textTransform={ - action.kind === "modifier" || action.kind === "clear" - ? "uppercase" - : "none" - } - /> - ); - })} - - - - - - ) : !keyboardState.isVisible ? ( - ({ - bottom: 16, - borderRadius: 28, - opacity: pressed ? 0.72 : 1, - position: "absolute", - right: 16, - })} - > - - - - - ) : null} + + + {terminalToolbarActions.map((action) => { + const active = + action.kind === "modifier" && pendingModifier === action.modifier; + + return ( + 1 ? 56 : 44} + onPress={() => handleToolbarActionPress(action)} + showChevron={false} + textTransform={ + action.kind === "modifier" || action.kind === "clear" + ? "uppercase" + : "none" + } + /> + ); + })} + + + + + + ) : !keyboardState.isVisible ? ( + ({ + bottom: 16, + borderRadius: 28, + opacity: pressed ? 0.72 : 1, + position: "absolute", + right: 16, + })} + > + + + + + ) : null} + + )} ); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts index c7418a52533..5cb37cbb0a9 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts @@ -43,10 +43,23 @@ describe("resolveNativeTerminalSurfaceView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + + expect(resolveNativeTerminalSurfaceView()).toBeNull(); expect(resolveNativeTerminalSurfaceView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3TerminalSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index c4686a38b4e..e5b1f630073 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; interface ExpoGlobalWithViewConfig { @@ -33,6 +35,7 @@ export interface NativeTerminalSurfaceProps extends ViewProps { } let cachedNativeTerminalSurfaceView: ComponentType | undefined; +let nativeTerminalSurfaceViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -45,6 +48,10 @@ export function resolveNativeTerminalSurfaceView(): ComponentType( NATIVE_TERMINAL_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeTerminalSurfaceViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_TERMINAL_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 048ce2ac409..48c87e18dd4 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 0e0e80ef5d9..29374bdda6d 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -1,4 +1,4 @@ -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import * as Arr from "effect/Array"; diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts new file mode 100644 index 00000000000..871a28d8528 --- /dev/null +++ b/apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadTerminalAttachInput, + threadTerminalSubscriptionKey, + type ThreadTerminalSubscriptionIdentity, +} from "./threadTerminalPanelModel"; + +const identity: ThreadTerminalSubscriptionIdentity = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "default", + cwd: "/repo", + worktreePath: "/repo", +}; + +describe("threadTerminalSubscriptionKey", () => { + it("does not include mutable terminal dimensions", () => { + const initialAttach = buildThreadTerminalAttachInput(identity, { cols: 80, rows: 24 }); + const resizedAttach = buildThreadTerminalAttachInput(identity, { cols: 132, rows: 40 }); + + expect(initialAttach).not.toEqual(resizedAttach); + expect(threadTerminalSubscriptionKey({ ...identity, ...initialAttach })).toBe( + threadTerminalSubscriptionKey({ ...identity, ...resizedAttach }), + ); + }); + + it.each([ + ["environment", { environmentId: EnvironmentId.make("env-2") }], + ["thread", { threadId: ThreadId.make("thread-2") }], + ["terminal", { terminalId: "term-2" }], + ["cwd", { cwd: "/repo/packages/app" }], + ["worktree", { worktreePath: "/repo/worktrees/feature" }], + ])("changes when the %s identity changes", (_label, update) => { + expect(threadTerminalSubscriptionKey({ ...identity, ...update })).not.toBe( + threadTerminalSubscriptionKey(identity), + ); + }); +}); diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts new file mode 100644 index 00000000000..9f1d032d264 --- /dev/null +++ b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts @@ -0,0 +1,40 @@ +import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; + +export interface ThreadTerminalSubscriptionIdentity { + readonly environmentId: EnvironmentId; + readonly threadId: TerminalAttachInput["threadId"]; + readonly terminalId: TerminalAttachInput["terminalId"]; + readonly cwd: string; + readonly worktreePath: string | null; +} + +export interface TerminalGridSize { + readonly cols: number; + readonly rows: number; +} + +export function threadTerminalSubscriptionKey( + identity: ThreadTerminalSubscriptionIdentity, +): string { + return JSON.stringify([ + identity.environmentId, + identity.threadId, + identity.terminalId, + identity.cwd, + identity.worktreePath, + ]); +} + +export function buildThreadTerminalAttachInput( + identity: ThreadTerminalSubscriptionIdentity, + gridSize: TerminalGridSize, +): TerminalAttachInput { + return { + threadId: identity.threadId, + terminalId: identity.terminalId, + cwd: identity.cwd, + worktreePath: identity.worktreePath, + cols: gridSize.cols, + rows: gridSize.rows, + }; +} diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 1b652a139cf..8b6fe078088 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -7,6 +7,7 @@ import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "rea import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; export type ComposerCommandItem = | { @@ -156,14 +157,22 @@ const CommandRow = memo(function CommandRow(props: { ) : null} {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -182,7 +191,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( {label ? ( {label} @@ -206,7 +215,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : ( - + {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 96fce3e3ccd..93d929e5961 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,11 +1,12 @@ import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Linking, Pressable, View } from "react-native"; +import { ActivityIndicator, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; @@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: { const handlePress = useCallback(() => { if (progress.prUrl) { - void Linking.openURL(progress.prUrl); + void tryOpenExternalUrl(progress.prUrl, "pull-request"); return; } if (progress.phase === "success" || progress.phase === "error") { @@ -73,12 +74,12 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.label ? ( - + {progress.label} ) : null} {progress.description ? ( - + {progress.description} ) : null} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index de95e3645bd..92c13c20070 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,11 +1,15 @@ import { Stack, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { InteractionManager, View, useColorScheme } from "react-native"; +import { Alert, InteractionManager, View, useColorScheme } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; -import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -19,27 +23,17 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; -import { useProjectActions } from "./use-project-actions"; - -function withModelSelectionOption( - selection: ModelSelection, - id: string, - value: string | boolean | undefined, -): ModelSelection { - const options = (selection.options ?? []).filter((option) => option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; -} - -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} +import { useCreateProjectThread } from "./use-project-actions"; function formatWorkspaceLabel(input: { readonly workspaceMode: string; @@ -59,8 +53,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(); @@ -169,39 +163,18 @@ export function NewTaskDraftScreen(props: { })), [flow.providerGroups, flow.selectedModel], ); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: flow.selectedModelOption?.capabilities, + selections: flow.selectedModel?.options, + }), + [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + ); const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: flow.effort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: flow.fastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: flow.fastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: flow.contextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: flow.contextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -241,7 +214,7 @@ export function NewTaskDraftScreen(props: { }), }, ], - [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], + [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], ); const workspaceMenuActions = useMemo(() => { @@ -302,14 +275,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({ @@ -338,16 +307,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:")) { @@ -415,42 +377,33 @@ export function NewTaskDraftScreen(props: { } 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: flow.selectedProject, + modelSelection: flow.selectedModel, + 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, + }); + flow.setSubmitting(false); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not start task", + error instanceof Error ? error.message : "The task could not be started.", + ); } - } finally { - flow.setSubmitting(false); + return; } + + flow.setPrompt(""); + flow.clearAttachments(); + router.replace(buildThreadRoutePath(result.value)); } if (!selectedProject) { @@ -478,7 +431,7 @@ export function NewTaskDraftScreen(props: { onPasteImages={(uris) => void handleNativePasteImages(uris)} placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - textStyle={{ fontSize: 18, lineHeight: 28 }} + textStyle={MOBILE_TYPOGRAPHY.composer} /> diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index eb9e929ed14..0617ef1cbcf 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -10,13 +10,13 @@ export interface PendingApprovalCardProps { readonly onRespond: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; } export function PendingApprovalCard(props: PendingApprovalCardProps) { return ( - + Approval needed diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c42a7ff34e0..c9e01777214 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -20,13 +20,13 @@ export interface PendingUserInputCardProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmit: () => Promise; + readonly onSubmit: () => Promise; } export function PendingUserInputCard(props: PendingUserInputCardProps) { return ( - + User input needed @@ -39,7 +39,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.header} - + {question.question} @@ -65,7 +65,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7d38353879e..0050eb923be 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, @@ -15,7 +15,14 @@ import { } from "@t3tools/shared/composerTrigger"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native"; +import { + ActivityIndicator, + Image, + Pressable, + useColorScheme, + View, + type ViewStyle, +} from "react-native"; import ImageViewing from "react-native-image-viewing"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -36,6 +43,7 @@ import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { insertRankedSearchResult, @@ -43,11 +51,12 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; import { - getModelSelectionBooleanOptionValue, - getModelSelectionStringOptionValue, -} from "@t3tools/shared/model"; + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; -import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; /** @@ -68,7 +77,9 @@ export interface ThreadComposerProps { readonly placeholder: string; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; - readonly selectedThread: OrchestrationThread; + readonly connectionError: string | null; + readonly environmentLabel: string | null; + readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; @@ -79,11 +90,12 @@ export interface ThreadComposerProps { readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; - readonly onStopThread: () => Promise; - readonly onSendMessage: () => void; - readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise; - readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise; - readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise; + readonly onStopThread: () => void; + readonly onSendMessage: () => Promise; + readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; + readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; + readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; } @@ -126,21 +138,67 @@ function ComposerSurface(props: { ); } -function withModelSelectionOption( - selection: ModelSelection, - id: string, - value: string | boolean | undefined, -): ModelSelection { - const options = (selection.options ?? []).filter((option) => option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; +function composerConnectionStatus(input: { + readonly connectionError: string | null; + readonly connectionState: RemoteClientConnectionState; + readonly environmentLabel: string | null; +}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { + kind: "reconnecting", + label: + input.connectionError === null + ? `Reconnecting to ${environmentLabel}...` + : `Failed to connect. Retrying ${environmentLabel}...`, + }; + case "offline": + return { kind: "unavailable", label: "You are offline" }; + case "error": + return { + kind: "unavailable", + label: input.connectionError + ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` + : `Failed to connect to ${environmentLabel}`, + }; + case "available": + return { kind: "unavailable", label: `${environmentLabel} is not connected` }; + case "connected": + return null; + } } -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} +const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { + readonly onPress: () => void; + readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; +}) { + const isReconnecting = props.status.kind === "reconnecting"; + + return ( + + + {isReconnecting ? ( + + ) : ( + + )} + + {props.status.label} + + + + ); +}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; @@ -154,7 +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,13 +240,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting" || - props.queueCount > 0; + props.selectedThread.session?.status === "starting"; - const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; + const sendLabel = + props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 + ? "Queue" + : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; + const connectionStatus = composerConnectionStatus({ + connectionError: props.connectionError, + connectionState: props.connectionState, + environmentLabel: props.environmentLabel, + }); const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; const selectedProviderStatus = useMemo(() => { @@ -200,18 +265,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - // Extract current model options (effort, fastMode, contextWindow) - const selectedProviderDriver = selectedProviderStatus?.driver ?? null; - const currentEffort = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high") - : "high"; - const currentFastMode = - getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; - const currentContextWindow = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") - : "1M"; // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ start: props.draftMessage.length, @@ -394,8 +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) => { @@ -413,7 +467,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - void onUpdateInteractionMode(item.command); + onUpdateInteractionMode(item.command); return; } @@ -452,14 +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); } } @@ -652,6 +651,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + - + +{props.draftAttachments.length - 3} @@ -755,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} @@ -830,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 d035f6eb909..624e8fe14fe 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, @@ -23,7 +24,7 @@ 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, @@ -39,13 +40,14 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; +import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { - readonly selectedThread: OrchestrationThread; + readonly selectedThread: OrchestrationThreadShell; + readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; + readonly environmentLabel: string | null; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; readonly activePendingApproval: PendingApproval | null; @@ -56,30 +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, @@ -90,7 +92,7 @@ export interface ThreadDetailScreenProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmitUserInput: () => Promise; + readonly onSubmitUserInput: () => Promise; readonly showContent?: boolean; } @@ -306,10 +308,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > ; - 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: Extract["activities"], -) { - return activities.map((activity) => ({ - ...activity, - detail: compactActivityDetail(activity.detail), - })); + return ( + props.onPressImage(uri)}> + + + ); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; - const MARKDOWN_COLORS = { light: { body: "#111111", @@ -128,10 +135,12 @@ const MARKDOWN_COLORS = { blockquoteBackground: "rgba(0, 0, 0, 0.02)", codeBackground: "rgba(0, 0, 0, 0.04)", codeText: "#262626", + inlineCodeText: "#5f6368", horizontalRule: "rgba(0, 0, 0, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.22)", userCodeText: "#ffffff", + userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.16)", userFenceText: "#ffffff", }, @@ -143,10 +152,12 @@ const MARKDOWN_COLORS = { blockquoteBackground: "rgba(255, 255, 255, 0.03)", codeBackground: "rgba(255, 255, 255, 0.06)", codeText: "#e5e5e5", + inlineCodeText: "#b8bcc2", horizontalRule: "rgba(255, 255, 255, 0.08)", userBody: "#ffffff", userCodeBackground: "rgba(255, 255, 255, 0.18)", userCodeText: "#ffffff", + userInlineCodeText: "rgba(255, 255, 255, 0.82)", userFenceBackground: "rgba(0, 0, 0, 0.28)", userFenceText: "#ffffff", }, @@ -175,27 +186,18 @@ interface ReviewCommentColors { const failedMarkdownFaviconHosts = new Set(); const markdownLinkStyles = StyleSheet.create({ - favicon: { + inlineIcon: { width: 14, height: 14, - borderRadius: 3, marginHorizontal: 3, transform: [{ translateY: 2 }], }, - file: { - borderRadius: 5, - borderWidth: StyleSheet.hairlineWidth, - fontFamily: "DMSans_500Medium", - fontSize: 13, - lineHeight: 20, - paddingHorizontal: 6, - paddingVertical: 2, + favicon: { + borderRadius: 3, }, - fileIcon: { - width: 15, - height: 15, - marginRight: 4, - transform: [{ translateY: 2 }], + file: { + fontFamily: "DMSans_700Bold", + fontWeight: "700", }, }); @@ -223,7 +225,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { source={{ uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`, }} - style={markdownLinkStyles.favicon} + style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]} onError={() => { failedMarkdownFaviconHosts.add(props.host); setFailed(true); @@ -260,11 +262,9 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(): MarkdownStyleSets { +function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { const colorScheme = useColorScheme(); const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; - const inlineChipBackground = String(useThemeColor("--color-subtle")); - const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { @@ -275,10 +275,12 @@ function useMarkdownStyles(): MarkdownStyleSets { const markdownBlockquoteBorder = colors.blockquoteBorder; const markdownCodeBg = colors.codeBackground; const markdownCodeText = colors.codeText; + const markdownInlineCodeText = colors.inlineCodeText; const markdownHrColor = colors.horizontalRule; const markdownUserBodyColor = colors.userBody; const markdownUserCodeBg = colors.userCodeBackground; const markdownUserCodeText = colors.userCodeText; + const markdownUserInlineCodeText = colors.userInlineCodeText; const markdownUserFenceBg = colors.userFenceBackground; const markdownUserFenceText = colors.userFenceText; @@ -367,28 +369,23 @@ function useMarkdownStyles(): MarkdownStyleSets { }; const createMarkdownRenderers = ( - inlineBackgroundColor: string, inlineTextColor: string, + inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, + preserveSoftBreaks: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} + style={[markdownLinkStyles.file, { color: inlineTextColor }]} > {presentation.label} @@ -448,8 +445,7 @@ function useMarkdownStyles(): MarkdownStyleSets { marginRight: 5, color: inlineTextColor, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, textAlign: ordered ? "right" : "center", }} > @@ -465,28 +461,24 @@ function useMarkdownStyles(): MarkdownStyleSets { ), code_inline: ({ content }) => { const value = content ?? ""; - const wrapsPoorly = - value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); return ( {value} ); }, + ...(preserveSoftBreaks + ? { + soft_break: () => {"\n"}, + } + : {}), code_block: ({ content, language }) => ( @@ -592,27 +584,26 @@ function useMarkdownStyles(): MarkdownStyleSets { theme: userTheme, styles: userStyles, renderers: createMarkdownRenderers( - markdownUserCodeBg, markdownUserCodeText, + markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, + true, ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, mutedColor: markdownUserBodyColor, linkColor: markdownUserBodyColor, + inlineCodeColor: markdownUserInlineCodeText, codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, - fileBackgroundColor: "rgba(255, 255, 255, 0.12)", fileTextColor: "#ffffff", - skillBackgroundColor: "rgba(217, 70, 239, 0.24)", - skillTextColor: "#ffffff", + skillTextColor: "#f0abfc", quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -622,39 +613,38 @@ function useMarkdownStyles(): MarkdownStyleSets { theme: assistantTheme, styles: assistantStyles, renderers: createMarkdownRenderers( - markdownCodeBg, markdownCodeText, + markdownInlineCodeText, markdownCodeBg, markdownCodeText, + false, ), nativeTextStyle: { color: markdownBodyColor, strongColor: markdownStrongColor, mutedColor: markdownBodyColor, linkColor: markdownLinkColor, + inlineCodeColor: markdownInlineCodeText, codeColor: markdownCodeText, codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, - fileBackgroundColor: inlineChipBackground, fileTextColor: markdownCodeText, - skillBackgroundColor: inlineSkillBackground, skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", }, }, }; - }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); + }, [colors, inlineSkillForeground, onLinkPress]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; @@ -665,11 +655,13 @@ function renderFeedEntry( readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onMarkdownLinkPress: (href: string) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; + readonly userBubbleMaxWidth: number; }, ) { const entry = info.item; @@ -718,9 +710,10 @@ function renderFeedEntry( return ( @@ -730,29 +723,18 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} + onLinkPress={props.onMarkdownLinkPress} /> ) : null} {attachments.map((attachment) => { - const uri = messageImageUrl(props.httpBaseUrl, attachment.id); - if (!uri) { - return null; - } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + environmentId={props.environmentId} + attachmentId={attachment.id} + className="aspect-[1.3] w-full rounded-[14px] bg-white/15" + onPressImage={props.onPressImage} + /> ); })} @@ -788,6 +770,7 @@ function renderFeedEntry( markdown={message.text} skills={props.skills} textStyle={styles.nativeTextStyle} + onLinkPress={props.onMarkdownLinkPress} /> ) : ( { - const uri = messageImageUrl(props.httpBaseUrl, attachment.id); - if (!uri) { - return null; - } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + environmentId={props.environmentId} + attachmentId={attachment.id} + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + onPressImage={props.onPressImage} + /> ); })} {showAssistantMeta ? ( @@ -849,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 ? ( @@ -866,123 +836,17 @@ function renderFeedEntry( ); } - const rows = buildActivityRows(entry.activities).filter( - (activity) => !(activity.toolLike && activity.status === "neutral"), - ); - if (rows.length === 0) { - return null; - } - const isExpanded = props.expandedWorkGroups[entry.id] ?? false; - const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; - const hiddenCount = rows.length - visibleRows.length; - const onlyToolRows = rows.every((row) => row.toolLike); - const headerTitle = onlyToolRows - ? rows.length === 1 - ? "1 tool call" - : `${rows.length} tool calls` - : "Work log"; - return ( - - - {headerTitle} - {hasOverflow ? ( - props.onToggleWorkGroup(entry.id)} - className="flex-row items-center gap-1" - > - - {isExpanded ? "Show less" : `Show ${hiddenCount} more`} - - - - ) : null} - - {visibleRows.map((row, index) => ( - { - if (row.fullDetail) { - props.onToggleWorkRow(row.id); - } - }} - onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} - className={cn( - "rounded-lg px-2 py-1.5", - index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", - )} - > - - - - - - {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - {row.fullDetail ? ( - - ) : null} - {props.copiedRowId === row.id ? ( - - Copied - - ) : null} - - {row.fullDetail && props.expandedWorkRows[row.id] ? ( - - - {row.fullDetail} - - - ) : null} - - ))} - + props.onToggleWorkGroup(entry.id)} + onToggleRow={props.onToggleWorkRow} + /> ); } @@ -991,6 +855,7 @@ function UserMessageContent(props: { readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; readonly skills?: ReadonlyArray; + readonly onLinkPress: (href: string) => void; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); @@ -1001,6 +866,8 @@ function UserMessageContent(props: { markdown={props.text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ); } @@ -1040,6 +907,8 @@ function UserMessageContent(props: { markdown={text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ) : ( @@ -1169,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()} @@ -1181,7 +1050,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { {props.comment.text} @@ -1220,7 +1089,39 @@ function compactFileName(filePath: string): string { return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } +function ThreadFeedPlaceholder(props: { + readonly bottomInset: number; + readonly detail: string; + readonly horizontalPadding: number; + readonly loading?: boolean; + readonly title: string; + readonly topInset: number; +}) { + return ( + + + {props.loading ? : null} + {props.title} + + {props.detail} + + + + ); +} + export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const router = useRouter(); const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); const scrollFrameRef = useRef(null); @@ -1250,6 +1151,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } | null>(null); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); + const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + 44; @@ -1257,16 +1159,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); - const markdownStyles = useMarkdownStyles(); + const onMarkdownLinkPress = useCallback( + (href: string) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + const relativePath = resolveWorkspaceRelativeFilePath( + props.workspaceRoot, + presentation.path, + ); + if (relativePath) { + void Haptics.selectionAsync(); + router.push( + buildThreadFilesNavigation( + { environmentId: props.environmentId, threadId: props.threadId }, + relativePath, + presentation.line, + ), + ); + } + return; + } + + if (presentation.href) { + void Linking.openURL(presentation.href); + } + }, + [props.environmentId, props.threadId, props.workspaceRoot, router], + ); + const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); const reviewCommentColors = useReviewCommentColors(); + // LegendList does not invalidate visible rows when only the renderItem closure changes. + // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. const listAppearanceData = useMemo( () => ({ + copiedRowId, + expandedWorkGroups, + expandedWorkRows, iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor, }), - [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], + [ + copiedRowId, + expandedWorkGroups, + expandedWorkRows, + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + ], ); const presentedFeed = useMemo( () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), @@ -1446,9 +1388,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { - bearerToken: props.bearerToken, + environmentId: props.environmentId, copiedRowId, - httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, expandedWorkRows, terminalAssistantMessageIds, @@ -1458,11 +1399,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, onToggleTurnFold, onPressImage, + onMarkdownLinkPress, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, skills: props.skills, }), [ @@ -1476,35 +1419,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, onCopyWorkRow, + onMarkdownLinkPress, onPressImage, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, - props.bearerToken, - props.httpBaseUrl, + props.environmentId, props.skills, ], ); + if (props.contentPresentation.kind === "loading") { + return ( + + ); + } + + if (props.contentPresentation.kind === "unavailable") { + return ( + + ); + } + if (props.feed.length === 0) { return ( - - - + ); } diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 500594f0ba7..d5920a72411 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -9,12 +9,13 @@ import { type GitActionRequestInput, requiresDefaultBranchConfirmation, resolveQuickAction, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/vcs"; import { useLocalSearchParams, useRouter } from "expo-router"; import Stack from "expo-router/stack"; import { useCallback, useMemo } from "react"; -import { Alert, Linking } from "react-native"; -import { buildThreadReviewRoutePath } from "../../lib/routes"; +import { Alert } from "react-native"; +import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { basename, getTerminalStatusLabel, @@ -69,6 +70,7 @@ export function ThreadGitControls(props: { readonly gitStatus: VcsStatusResult | null; readonly gitOperationLabel: string | null; readonly canOpenTerminal: boolean; + readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; readonly onOpenTerminal: (terminalId?: string | null) => void; @@ -124,13 +126,8 @@ export function ThreadGitControls(props: { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus]); @@ -259,6 +256,14 @@ export function ThreadGitControls(props: { > Review changes + router.push(buildThreadFilesNavigation({ environmentId, threadId }))} + subtitle="Browse this workspace" + > + Files + diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 84ae71dce5c..9318fb76017 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -1,6 +1,13 @@ import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { + type ColorValue, + Modal, + Pressable, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -15,21 +22,19 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { StatusPill } from "../../components/StatusPill"; +import { useProjects, useThreadShells } from "../../state/entities"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; import { threadStatusTone } from "./threadPresentation"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const threadActivityOrder = Order.mapInput( Order.Struct({ activityAt: Order.flip(Order.Number), title: Order.String, }), - (thread: EnvironmentScopedThreadShell) => ({ + (thread: EnvironmentThreadShell) => ({ activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), title: thread.title, }), @@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput( export function ThreadNavigationDrawer(props: { readonly visible: boolean; - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; readonly selectedThreadKey: string | null; readonly onClose: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onStartNewTask: () => void; }) { const insets = useSafeAreaInsets(); @@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: { const primaryForeground = useThemeColor("--color-primary-foreground"); const borderSubtleColor = useThemeColor("--color-border-subtle"); - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), - [props.projects, props.threads], - ); - const groupedThreads = useMemo( - () => - repositoryGroups.map((group) => { - const threads: EnvironmentScopedThreadShell[] = []; - for (const projectGroup of group.projects) { - threads.push(...projectGroup.threads); - } - return { - key: group.key, - title: group.projects[0]?.project.title ?? group.title, - threads: Arr.sort(threads, threadActivityOrder), - }; - }), - [repositoryGroups], - ); - useEffect(() => { if (props.visible) { setMounted(true); @@ -169,7 +152,7 @@ export function ThreadNavigationDrawer(props: { ]} > - Threads + Threads { props.onClose(); @@ -186,76 +169,114 @@ export function ThreadNavigationDrawer(props: { - - {groupedThreads.map((group) => ( - - - {group.title} - - - - {group.threads.length === 0 ? ( - - - No threads yet - - - ) : ( - group.threads.map((thread, index) => { - const threadKey = scopedThreadKey(thread.environmentId, thread.id); - const selected = props.selectedThreadKey === threadKey; - - return ( - { - props.onSelectThread(thread); - props.onClose(); - }} - style={{ - paddingHorizontal: 16, - paddingVertical: 15, - borderTopWidth: index === 0 ? 0 : 1, - borderTopColor: borderSubtleColor, - backgroundColor: selected ? undefined : "transparent", - }} - className={selected ? "bg-subtle" : undefined} - > - - - - {thread.title} - - - {relativeTime(thread.updatedAt ?? thread.createdAt)} - - - - - - ); - }) - )} - - - ))} - + ); } + +function ThreadNavigationDrawerContent(props: { + readonly bottomInset: number; + readonly borderSubtleColor: ColorValue; + readonly selectedThreadKey: string | null; + readonly onClose: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; +}) { + const projects = useProjects(); + const threads = useThreadShells(); + const repositoryGroups = useMemo( + () => groupProjectsByRepository({ projects, threads }), + [projects, threads], + ); + const groupedThreads = useMemo( + () => + repositoryGroups.map((group) => { + const threads: EnvironmentThreadShell[] = []; + for (const projectGroup of group.projects) { + threads.push(...projectGroup.threads); + } + return { + key: group.key, + title: group.projects[0]?.project.title ?? group.title, + threads: Arr.sort(threads, threadActivityOrder), + }; + }), + [repositoryGroups], + ); + + return ( + + {groupedThreads.map((group) => ( + + + {group.title} + + + + {group.threads.length === 0 ? ( + + No threads yet + + ) : ( + group.threads.map((thread, index) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const selected = props.selectedThreadKey === threadKey; + + return ( + { + props.onSelectThread(thread); + props.onClose(); + }} + style={{ + paddingHorizontal: 16, + paddingVertical: 15, + borderTopWidth: index === 0 ? 0 : 1, + borderTopColor: props.borderSubtleColor, + backgroundColor: selected ? undefined : "transparent", + }} + className={selected ? "bg-subtle" : undefined} + > + + + + {thread.title} + + + {relativeTime(thread.updatedAt ?? thread.createdAt)} + + + + + + ); + }) + )} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 84c4b343a93..7bb74ae88ff 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,28 +1,35 @@ 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 { + EnvironmentId, + type ModelSelection, + type ProjectScript, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { useWorkspaceState } from "../../state/workspace"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useVcsStatus } from "../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { + useRemoteConnections, useRemoteConnectionStatus, - useRemoteEnvironmentState, + useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -38,12 +45,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 +67,31 @@ 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 updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread metadata update", + ); + const setThreadRuntimeMode = useAtomCommand( + threadEnvironment.setRuntimeMode, + "thread runtime mode", + ); + const setThreadInteractionMode = useAtomCommand( + threadEnvironment.setInteractionMode, + "thread interaction mode", + ); + const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string | string[]; @@ -83,12 +101,10 @@ 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; /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -96,10 +112,14 @@ export function ThreadRouteScreen() { const secondaryFg = String(useThemeColor("--color-foreground-secondary")); /* ─── Git status for native header trigger ───────────────────────── */ - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, @@ -113,6 +133,12 @@ export function ThreadRouteScreen() { [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; + const handleReconnectEnvironment = useCallback(() => { + if (!environmentId) { + return; + } + onReconnectEnvironment(environmentId); + }, [environmentId, onReconnectEnvironment]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -131,6 +157,69 @@ export function ThreadRouteScreen() { const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); }, [router]); + const handleUpdateThreadModelSelection = useCallback( + (modelSelection: ModelSelection) => { + if (!selectedThread) { + return; + } + return updateThreadMetadata({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + modelSelection, + }, + }); + }, + [selectedThread, updateThreadMetadata], + ); + const handleUpdateThreadRuntimeMode = useCallback( + (runtimeMode: RuntimeMode) => { + if (!selectedThread) { + return; + } + return setThreadRuntimeMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + runtimeMode, + }, + }); + }, + [selectedThread, setThreadRuntimeMode], + ); + const handleUpdateThreadInteractionMode = useCallback( + (interactionMode: ProviderInteractionMode) => { + if (!selectedThread) { + return; + } + return setThreadInteractionMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + interactionMode, + }, + }); + }, + [selectedThread, setThreadInteractionMode], + ); + const handleStopThread = useCallback(() => { + if ( + !selectedThread || + (selectedThread.session?.status !== "running" && + selectedThread.session?.status !== "starting") + ) { + return; + } + return interruptThreadTurn({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + ...(selectedThread.session.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + }, + }); + }, [interruptThreadTurn, selectedThread]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -238,7 +327,7 @@ export function ThreadRouteScreen() { if (!selectedThread) { const stillHydrating = - isLoadingSavedConnection || + workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; @@ -265,19 +354,14 @@ export function ThreadRouteScreen() { ); } - if (!selectedThreadDetail) { - return ; - } - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const serverConfig = - routeEnvironmentRuntime?.serverConfig ?? - pipe( - Object.values(environmentStateById), - Arr.map((runtime) => runtime.serverConfig), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ); + const contentPresentation = projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); + const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const headerSubtitle = [ selectedThreadProject?.title ?? null, @@ -307,19 +391,19 @@ export function ThreadRouteScreen() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", color: foregroundColor, letterSpacing: -0.4, }} > - {selectedThreadDetail.title} + {selectedThread.title} setDrawerVisible(false)} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/threads/claudeEffortOptions.ts b/apps/mobile/src/features/threads/claudeEffortOptions.ts deleted file mode 100644 index 58a4032b0ba..00000000000 --- a/apps/mobile/src/features/threads/claudeEffortOptions.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; - -export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index a6b29fbe431..e27136702f2 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitBranchesSheet() { @@ -27,10 +28,14 @@ export function GitBranchesSheet() { const foregroundColor = useThemeColor("--color-foreground"); const subtleStrongColor = useThemeColor("--color-subtle-strong"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; @@ -64,7 +69,7 @@ export function GitBranchesSheet() { > New branch @@ -73,7 +78,7 @@ export function GitBranchesSheet() { value={newBranchName} onChangeText={setNewBranchName} placeholder="feature/mobile-polish" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -99,7 +104,7 @@ export function GitBranchesSheet() { New worktree @@ -108,7 +113,7 @@ export function GitBranchesSheet() { value={worktreeBaseBranch} onChangeText={setWorktreeBaseBranch} placeholder="main" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -120,7 +125,7 @@ export function GitBranchesSheet() { value={worktreeBranchName} onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" - className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" + className="rounded-[18px] px-3.5 py-3 font-sans text-base" style={{ borderWidth: 1, borderColor: inputBorderColor, @@ -149,18 +154,16 @@ export function GitBranchesSheet() { Existing branches {branchesLoading ? ( - - Loading branches... - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -190,8 +193,8 @@ export function GitBranchesSheet() { }} > - {branch.name} - {subtitle} + {branch.name} + {subtitle} ); })} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 478e2642035..76e0daf5f0a 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitCommitSheet() { @@ -27,10 +28,14 @@ export function GitCommitSheet() { const inputBg = useThemeColor("--color-input"); const foregroundColor = useThemeColor("--color-foreground"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const busy = gitState.gitOperationLabel !== null; const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; @@ -74,14 +79,14 @@ export function GitCommitSheet() { > - Branch - + Branch + {gitStatus.data?.refName ?? "(detached HEAD)"} {isDefaultRef ? ( Warning: this is the default branch. @@ -92,8 +97,8 @@ export function GitCommitSheet() { - Files - + Files + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} @@ -103,14 +108,14 @@ export function GitCommitSheet() { className="bg-subtle rounded-full px-3 py-2" onPress={() => setExcludedFiles(new Set())} > - Reset + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -118,26 +123,26 @@ export function GitCommitSheet() { {allFiles.length === 0 ? ( - + No changed files are available to commit. ) : !isEditingFiles ? ( {selectedFilePreview.map((file) => ( - + {file.path} - + +{file.insertions} - + -{file.deletions} ))} {selectedFiles.length > selectedFilePreview.length ? ( - + +{selectedFiles.length - selectedFilePreview.length} more files ) : null} @@ -172,21 +177,21 @@ export function GitCommitSheet() { {file.path} {!included ? ( - + Excluded from this commit ) : null} - + +{file.insertions} - + -{file.deletions} @@ -199,14 +204,14 @@ export function GitCommitSheet() { - Commit message + Commit message Confirm - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index a940fcdfcc3..0db7876a774 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -3,22 +3,24 @@ import { buildMenuItems, getGitActionDisabledReason, requiresDefaultBranchConfirmation, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo } from "react"; -import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; +import { Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { buildThreadReviewRoutePath } from "../../../lib/routes"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; export function GitOverviewSheet() { @@ -36,10 +38,14 @@ export function GitOverviewSheet() { const iconColor = useThemeColor("--color-icon"); const borderColor = useThemeColor("--color-border"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; @@ -78,13 +84,8 @@ export function GitOverviewSheet() { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus.data]); @@ -170,13 +171,13 @@ export function GitOverviewSheet() { /> Branch - {currentBranchLabel} - + {currentBranchLabel} + {statusSummary(gitStatus.data)} diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index b13f6a3020c..16c311bff57 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -56,7 +56,7 @@ export function SheetActionButton(props: { > {props.label} @@ -69,12 +69,12 @@ export function MetaCard(props: { readonly label: string; readonly value: string return ( {props.label} - + {props.value} @@ -102,9 +102,9 @@ export function SheetListRow(props: { - {props.title} + {props.title} {props.subtitle ? ( - {props.subtitle} + {props.subtitle} ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index fb93d379a7f..3e95a039c0e 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import type { EnvironmentId, ModelSelection, ProviderInteractionMode, + ProviderOptionSelection, RuntimeMode, ServerProviderSkill, } from "@t3tools/contracts"; @@ -11,6 +12,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tool import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -23,21 +25,17 @@ import { setComposerDraftText, useComposerDraft, } from "../../state/use-composer-drafts"; -import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { useBranches } from "../../state/queries"; import { setPendingConnectionError, - useRemoteEnvironmentState, + useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; -import type { ClaudeAgentEffort } from "./claudeEffortOptions"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; -function normalizeSelectedWorktreePath( - project: EnvironmentScopedProjectShell, - branch: VcsRef, -): string | null { +function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { if (!branch.worktreePath) { return null; } @@ -47,7 +45,7 @@ function normalizeSelectedWorktreePath( export function branchBadgeLabel(input: { readonly branch: VcsRef; - readonly project: EnvironmentScopedProjectShell | null; + readonly project: EnvironmentProject | null; }): string | null { if (input.branch.current) { return "current"; @@ -67,7 +65,7 @@ export function branchBadgeLabel(input: { type NewTaskFlowContextValue = { readonly logicalProjects: ReadonlyArray<{ readonly key: string; - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; }>; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -83,15 +81,12 @@ type NewTaskFlowContextValue = { readonly availableBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; - readonly effort: ClaudeAgentEffort; - readonly fastMode: boolean; - readonly contextWindow: string; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }>; - readonly selectedProject: EnvironmentScopedProjectShell | null; + readonly selectedProject: EnvironmentProject | null; readonly modelOptions: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; @@ -99,7 +94,7 @@ type NewTaskFlowContextValue = { readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; - readonly setProject: (project: EnvironmentScopedProjectShell) => void; + readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: (key: string | null) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; @@ -114,17 +109,18 @@ type NewTaskFlowContextValue = { readonly loadBranches: () => Promise; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; - readonly setEffort: (value: ClaudeAgentEffort) => void; - readonly setFastMode: (value: boolean) => void; - readonly setContextWindow: (value: string) => void; + readonly setSelectedModelOptions: ( + value: ReadonlyArray | undefined, + ) => void; readonly setExpandedProvider: (value: string | null) => void; }; const NewTaskFlowContext = React.createContext(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) { - const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { savedConnectionsById } = useSavedRemoteConnections(); const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), @@ -146,16 +142,21 @@ 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"); @@ -168,17 +169,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [interactionMode, setInteractionMode] = useState( DEFAULT_PROVIDER_INTERACTION_MODE, ); - const [effort, setEffort] = useState("high"); - const [fastMode, setFastMode] = useState(false); - const [contextWindow, setContextWindow] = useState("1M"); + const [modelSelectionOverrides, setModelSelectionOverrides] = useState< + Record + >({}); 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"); @@ -188,22 +185,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setBranchQuery(""); setRuntimeMode(DEFAULT_RUNTIME_MODE); setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setEffort("high"); - setFastMode(false); - setContextWindow("1M"); + setModelSelectionOverrides({}); setExpandedProvider(null); - }, [projects]); - - useEffect(() => { - if (selectedEnvironmentId !== null || projects.length === 0) { - return; - } - - console.log("[new task flow] initializing environment", { - environmentId: projects[0]!.environmentId, - }); - setSelectedEnvironmentId(projects[0]!.environmentId); - }, [projects, selectedEnvironmentId]); + }, []); const environments = useMemo( () => @@ -254,6 +238,9 @@ 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; @@ -264,19 +251,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const modelOptions = useMemo( () => buildModelOptions( - selectedProject - ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) - : null, + selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ), - [selectedProject, serverConfigByEnvironmentId], + [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], ); - const selectedModel = + const defaultModelKey = selectedProject?.defaultModelSelection + ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` + : null; + const baseSelectedModel = modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? + (defaultModelKey + ? modelOptions.find((option) => option.key === defaultModelKey)?.selection + : null) ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; + const selectedModelIdentity = baseSelectedModel + ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + : null; + const selectedModel = + (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? + baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -286,11 +283,27 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.model === selectedModel.model, ) ?? null; const selectedProviderSkills = - (selectedProject - ? serverConfigByEnvironmentId[selectedProject.environmentId] - : null - )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? - []; + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? []; + const setSelectedModelOptions = useCallback( + (options: ReadonlyArray | undefined) => { + if (!selectedModel || !selectedModelIdentity) { + return; + } + const nextSelection: ModelSelection = options + ? { ...selectedModel, options } + : { + instanceId: selectedModel.instanceId, + model: selectedModel.model, + }; + setModelSelectionOverrides((current) => ({ + ...current, + [selectedModelIdentity]: nextSelection, + })); + }, + [selectedModel, selectedModelIdentity], + ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( @@ -343,7 +356,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }), [selectedProject?.environmentId, selectedProject?.workspaceRoot], ); - const branchState = useVcsRefs(branchTarget); + const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; const availableBranches = useMemo( () => @@ -366,13 +379,14 @@ 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); + setModelSelectionOverrides({}); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { @@ -381,6 +395,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedProjectKey(null); setSelectedBranchName(null); setSelectedWorktreePath(null); + setModelSelectionOverrides({}); }, []); const selectBranch = useCallback( @@ -400,37 +415,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { 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); - } - } - } catch { - if (loadVersion !== branchLoadVersionRef.current) { - return; + branchState.refresh(); + if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + return; + } + setPendingConnectionError(null); + if (workspaceMode === "worktree" && !selectedBranchName) { + const preferredBranch = + availableBranches.find((branch) => branch.current)?.name ?? + availableBranches.find((branch) => branch.isDefault)?.name ?? + null; + if (preferredBranch) { + setSelectedBranchName(preferredBranch); } - setPendingConnectionError("Failed to load branches."); } - }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); + }, [ + availableBranches, + branchState, + selectedBranchName, + selectedProject, + selectedProjectKey, + workspaceMode, + ]); const value = useMemo( () => ({ @@ -449,9 +455,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, runtimeMode, interactionMode, - effort, - fastMode, - contextWindow, expandedProvider, environments, selectedProject, @@ -477,9 +480,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { loadBranches, setRuntimeMode, setInteractionMode, - setEffort, - setFastMode, - setContextWindow, + setSelectedModelOptions, setExpandedProvider, }), [ @@ -487,11 +488,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, branchQuery, branchesLoading, - contextWindow, - effort, environments, expandedProvider, - fastMode, filteredBranches, interactionMode, loadBranches, @@ -508,6 +506,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModelKey, selectedModelOption, selectedProviderSkills, + setSelectedModelOptions, selectedProject, selectedProjectKey, selectedWorktreePath, @@ -522,24 +521,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ], ); - useEffect(() => { - console.log("[new task flow] state", { - availableBranchCount: availableBranches.length, - environmentCount: environments.length, - logicalProjectCount: logicalProjects.length, - selectedEnvironmentId, - selectedProjectKey, - selectedProjectTitle: selectedProject?.title ?? null, - }); - }, [ - availableBranches.length, - environments.length, - logicalProjects.length, - selectedEnvironmentId, - selectedProject?.title, - selectedProjectKey, - ]); - return {props.children}; } diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts new file mode 100644 index 00000000000..e4ad776e23d --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class ProjectThreadTaskRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadTaskRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + environmentMode: Schema.Literals(["local", "worktree"]), + }, +) { + override get message(): string { + return "Enter a task before starting the thread."; + } +} + +export class ProjectThreadBaseBranchRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadBaseBranchRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + }, +) { + override get message(): string { + return "Select a base branch before creating a worktree."; + } +} + +export const ProjectThreadCreationValidationError = Schema.Union([ + ProjectThreadTaskRequiredError, + ProjectThreadBaseBranchRequiredError, +]); +export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; + +export function validateProjectThreadCreation(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly environmentMode: "local" | "worktree"; + readonly branch: string | null; + readonly initialMessageText: string; +}): ProjectThreadCreationValidationError | null { + if (input.initialMessageText.trim().length === 0) { + return new ProjectThreadTaskRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + environmentMode: input.environmentMode, + }); + } + if (input.environmentMode === "worktree" && !input.branch) { + return new ProjectThreadBaseBranchRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + }); + } + return null; +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx new file mode 100644 index 00000000000..707e1a24f0d --- /dev/null +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -0,0 +1,261 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView, type SFSymbol } from "expo-symbols"; +import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import type { ThreadFeedActivity } from "../../lib/threadActivity"; + +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; +const WORK_LOG_LAYOUT_ANIMATION = { + duration: 180, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + delete: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, +} as const; + +function triggerDisclosureFeedback() { + LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); + void Haptics.selectionAsync(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +function compactActivityDetail(detail: string | null): string | null { + if (!detail) { + return null; + } + + const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { + switch (icon) { + case "agent": + return "sparkles"; + case "alert": + return "exclamationmark.triangle"; + case "check": + return "checkmark"; + case "command": + return "terminal"; + case "edit": + return "square.and.pencil"; + case "eye": + return "eye"; + case "globe": + return "globe"; + case "hammer": + return "hammer"; + case "message": + return "bubble.left"; + case "warning": + return "xmark"; + case "wrench": + return "wrench"; + case "zap": + return "bolt"; + } +} + +export function ThreadWorkLog(props: { + readonly activities: ReadonlyArray; + readonly copiedRowId: string | null; + readonly expanded: boolean; + readonly expandedRows: Readonly>; + readonly iconSubtleColor: import("react-native").ColorValue; + readonly onCopyRow: (rowId: string, value: string) => void; + readonly onToggleGroup: () => void; + readonly onToggleRow: (rowId: string) => void; +}) { + const colorScheme = useColorScheme(); + const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; + const rows = props.activities + .filter((activity) => !(activity.toolLike && activity.status === "neutral")) + .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); + + if (rows.length === 0) { + return null; + } + + const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleRows = + hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; + const hiddenCount = rows.length - visibleRows.length; + const onlyToolRows = rows.every((row) => row.toolLike); + + return ( + + {!onlyToolRows ? ( + + work log + + ) : null} + + + {visibleRows.map((row) => { + const expanded = props.expandedRows[row.id] ?? false; + const canExpand = row.fullDetail !== null; + const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; + const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; + + return ( + + { + if (canExpand) { + triggerDisclosureFeedback(); + props.onToggleRow(row.id); + } + }} + onLongPress={() => props.onCopyRow(row.id, row.copyText)} + style={({ pressed }) => ({ + backgroundColor: pressed ? pressedBackground : "transparent", + })} + className="rounded-md px-0.5 py-0.5" + > + + + + + + + + {row.summary} + + {row.detail ? ( + {row.detail} + ) : null} + + + + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + {canExpand ? ( + + ) : null} + + + {row.status ? ( + + ) : null} + + + + + + {expanded && row.fullDetail ? ( + + + + {row.fullDetail} + + + + ) : null} + + ); + })} + + + {hasOverflow ? ( + { + triggerDisclosureFeedback(); + props.onToggleGroup(); + }} + style={({ pressed }) => ({ + backgroundColor: pressed ? pressedBackground : "transparent", + })} + className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5" + > + + + + + {props.expanded + ? "Show fewer tool calls" + : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/threadContentPresentation.test.ts b/apps/mobile/src/features/threads/threadContentPresentation.test.ts new file mode 100644 index 00000000000..f179e756fbf --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { projectThreadContentPresentation } from "./threadContentPresentation"; + +describe("thread content presentation", () => { + it("renders cached detail while its environment reconnects", () => { + expect( + projectThreadContentPresentation({ + hasDetail: true, + detailError: null, + detailDeleted: false, + connectionState: "reconnecting", + }), + ).toEqual({ kind: "ready" }); + }); + + it("loads missing detail inside the thread screen when connected", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ kind: "loading" }); + }); + + it("explains uncached detail while disconnected instead of loading forever", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "error", + }), + ).toEqual({ + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }); + }); + + it("surfaces detail errors before presenting a loading state", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: "The thread stream failed.", + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ + kind: "unavailable", + title: "Could not load conversation", + detail: "The thread stream failed.", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/threadContentPresentation.ts b/apps/mobile/src/features/threads/threadContentPresentation.ts new file mode 100644 index 00000000000..c806e6dfc46 --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.ts @@ -0,0 +1,43 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export type ThreadContentPresentation = + | { readonly kind: "ready" } + | { readonly kind: "loading" } + | { + readonly kind: "unavailable"; + readonly title: string; + readonly detail: string; + }; + +export function projectThreadContentPresentation(input: { + readonly hasDetail: boolean; + readonly detailError: string | null; + readonly detailDeleted: boolean; + readonly connectionState: EnvironmentConnectionPhase; +}): ThreadContentPresentation { + if (input.hasDetail) { + return { kind: "ready" }; + } + if (input.detailDeleted) { + return { + kind: "unavailable", + title: "Thread unavailable", + detail: "This thread was deleted or is no longer available.", + }; + } + if (input.detailError !== null) { + return { + kind: "unavailable", + title: "Could not load conversation", + detail: input.detailError, + }; + } + if (input.connectionState === "connected") { + return { kind: "loading" }; + } + return { + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 4253cedbc7e..cf5eb1817a4 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 029e1bbdcf6..9531567f447 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -1,67 +1,27 @@ import { useCallback } from "react"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { CommandId, - DEFAULT_PROVIDER_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, - type EnvironmentId, MessageId, ThreadId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { uuidv4 } from "../../lib/uuid"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { threadEnvironment } from "../../state/threads"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { environmentRuntimeManager } from "../../state/use-environment-runtime"; -import { vcsRefManager } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { - setPendingConnectionError, - useRemoteEnvironmentState, -} from "../../state/use-remote-environment-registry"; - -function useRefreshRemoteData() { - const { savedConnectionsById } = useRemoteEnvironmentState(); - - return useCallback( - async (environmentIds?: ReadonlyArray) => { - const targets = - environmentIds ?? - Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - }, - [savedConnectionsById], - ); -} +import { uuidv4 } from "../../lib/uuid"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; +import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -73,13 +33,12 @@ function deriveThreadTitleFromPrompt(value: string): string { return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; } -export function useProjectActions() { - const { threads } = useRemoteCatalog(); - const refreshRemoteData = useRefreshRemoteData(); +export function useCreateProjectThread() { + const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - const onCreateThreadWithOptions = useCallback( + return useCallback( async (input: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; readonly modelSelection: ModelSelection; readonly envMode: "local" | "worktree"; readonly branch: string | null; @@ -89,174 +48,77 @@ export function useProjectActions() { readonly initialMessageText: string; readonly initialAttachments: ReadonlyArray; }) => { - const client = getEnvironmentClient(input.project.environmentId); - if (!client) { - return null; - } - const metadata = makeTurnCommandMetadata(); const threadId = ThreadId.make(metadata.threadId); - const createdAt = metadata.createdAt; const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); - if (initialMessageText.length === 0) { - return null; - } - if (input.envMode === "worktree" && !input.branch) { - return null; + const validationError = validateProjectThreadCreation({ + environmentId: input.project.environmentId, + projectId: input.project.id, + environmentMode: input.envMode, + branch: input.branch, + initialMessageText, + }); + if (validationError !== null) { + setPendingConnectionError(validationError.message); + return AsyncResult.failure(Cause.fail(validationError)); } const isWorktree = input.envMode === "worktree"; - - await client.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: CommandId.make(metadata.commandId), - threadId, - message: { - messageId: MessageId.make(metadata.messageId), - role: "user", - text: initialMessageText, - attachments: input.initialAttachments, - }, - modelSelection: input.modelSelection, - titleSeed: nextTitle, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - bootstrap: { - createThread: { - projectId: input.project.id, - title: nextTitle, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: input.branch, - worktreePath: isWorktree ? null : input.worktreePath, - createdAt, + const result = await startTurn({ + environmentId: input.project.environmentId, + input: { + commandId: CommandId.make(metadata.commandId), + threadId, + message: { + messageId: MessageId.make(metadata.messageId), + role: "user", + text: initialMessageText, + attachments: input.initialAttachments, }, - ...(isWorktree - ? { - prepareWorktree: { - projectCwd: input.project.workspaceRoot, - baseBranch: input.branch!, - branch: buildTemporaryWorktreeBranchName(uuidv4), - }, - runSetupScript: true, - } - : {}), + modelSelection: input.modelSelection, + titleSeed: nextTitle, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + bootstrap: { + createThread: { + projectId: input.project.id, + title: nextTitle, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: input.branch, + worktreePath: isWorktree ? null : input.worktreePath, + createdAt: metadata.createdAt, + }, + ...(isWorktree + ? { + prepareWorktree: { + projectCwd: input.project.workspaceRoot, + baseBranch: input.branch!, + branch: buildTemporaryWorktreeBranchName(uuidv4), + }, + runSetupScript: true, + } + : {}), + }, + createdAt: metadata.createdAt, }, - createdAt, }); - - await refreshRemoteData([input.project.environmentId]); - return { - environmentId: input.project.environmentId, - threadId, - }; - }, - [refreshRemoteData], - ); - - const onCreateThread = useCallback( - async (project: EnvironmentScopedProjectShell) => { - const latestProjectThread = - threads.find( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ) ?? null; - const modelSelection = - project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null; - if (!modelSelection) { - setPendingConnectionError("This project does not have a default model configured yet."); - return null; - } - - return await onCreateThreadWithOptions({ - project, - modelSelection, - envMode: "local", - branch: null, - worktreePath: null, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - initialMessageText: "", - initialAttachments: [], - }); - }, - [onCreateThreadWithOptions, threads], - ); - - const onListProjectBranches = useCallback( - async (project: EnvironmentScopedProjectShell): Promise> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, - client.vcs, - { limit: 100 }, - ); - return (result?.refs ?? []).filter((branch) => !branch.isRemote); - } catch (error) { + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", + error instanceof Error ? error.message : "The task could not be started.", ); - return []; - } - }, - [], - ); - - const onCreateProjectWorktree = useCallback( - async ( - project: EnvironmentScopedProjectShell, - nextWorktree: { - readonly baseBranch: string; - readonly newBranch: string; - }, - ): Promise<{ - readonly branch: string; - readonly worktreePath: string; - } | null> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return null; + return AsyncResult.failure(result.cause); } + setPendingConnectionError(null); - try { - const result = await client.vcs.createWorktree({ - cwd: project.workspaceRoot, - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }); - vcsRefManager.invalidate({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - query: null, - }); - return { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }; - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to create worktree.", - ); - return null; - } + return mapAtomCommandResult(result, () => + scopeThreadRef(input.project.environmentId, threadId), + ); }, - [], + [startTurn], ); - - return { - onCreateThread, - onCreateThreadWithOptions, - onListProjectBranches, - onCreateProjectWorktree, - onRefreshProjects: refreshRemoteData, - }; } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index b341c7b6bd4..09897b6186e 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,7 +1,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { Platform } from "react-native"; -export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(): AuthClientPresentationMetadata { return { label: "T3 Code Mobile", deviceType: "mobile", diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 68813b0b3b1..f1f30b298b6 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - mobileAuthClientMetadata, + authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); @@ -22,7 +22,7 @@ vi.mock("react-native", () => ({ describe("mobile remote connection records", () => { it("identifies mobile token exchanges for authorized-client presentation", () => { - expect(mobileAuthClientMetadata()).toEqual({ + expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index aa92c6f5d58..839bc70e6d9 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,18 +1,8 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, -} from "@t3tools/client-runtime"; -import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; -import * as Effect from "effect/Effect"; -import { mobileAuthClientMetadata } from "./authClientMetadata"; -import { mobileRuntime } from "./runtime"; +import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { mobileAuthClientMetadata } from "./authClientMetadata"; - -export interface RemoteConnectionInput { - readonly pairingUrl: string; -} +export { authClientMetadata } from "./authClientMetadata"; export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; @@ -27,12 +17,7 @@ export interface SavedRemoteConnection { readonly relayManaged?: true; } -export type RemoteClientConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; +export type RemoteClientConnectionState = EnvironmentConnectionPhase; export function redactPairingCredential(pairingUrl: string): string { const trimmed = pairingUrl.trim(); @@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection( const { dpopAccessToken: _, ...stableConnection } = connection; return stableConnection; } - -export async function bootstrapRemoteConnection( - input: RemoteConnectionInput, -): Promise { - const target = resolveRemotePairingTarget({ - pairingUrl: input.pairingUrl, - }); - - const { descriptor, bootstrap } = await mobileRuntime.runPromise( - Effect.all( - { - descriptor: fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: target.httpBaseUrl, - }), - bootstrap: bootstrapRemoteBearerSession({ - httpBaseUrl: target.httpBaseUrl, - credential: target.credential, - clientMetadata: mobileAuthClientMetadata(), - }), - }, - { concurrency: "unbounded" }, - ), - ); - - return { - environmentId: descriptor.environmentId, - environmentLabel: descriptor.label, - pairingUrl: redactPairingCredential(input.pairingUrl), - displayUrl: target.httpBaseUrl, - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, - bearerToken: bootstrap.access_token, - authenticationMethod: "bearer", - }; -} diff --git a/apps/mobile/src/lib/mobileLayout.ts b/apps/mobile/src/lib/layout.ts similarity index 74% rename from apps/mobile/src/lib/mobileLayout.ts rename to apps/mobile/src/lib/layout.ts index 0ae284e463f..2ae4314fdba 100644 --- a/apps/mobile/src/lib/mobileLayout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -2,19 +2,16 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export type MobileLayoutVariant = "compact" | "split"; +export type LayoutVariant = "compact" | "split"; -export interface MobileLayout { - readonly variant: MobileLayoutVariant; +export interface Layout { + readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; readonly shellPadding: number; } -export function deriveMobileLayout(input: { - readonly width: number; - readonly height: number; -}): MobileLayout { +export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; const shortestEdge = Math.min(width, height); const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 90153d0afa0..ff57287b741 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -16,26 +16,47 @@ describe("resolveMarkdownLinkPresentation", () => { resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), ).toEqual({ kind: "file", + href: "file:///Users/julius/project/src/main.ts#L42C7", icon: "typescript", label: "main.ts:42:7", + path: "/Users/julius/project/src/main.ts", + line: 42, + column: 7, }); }); it("recognizes relative source paths and bare filenames", () => { expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ kind: "file", + href: "apps/mobile/src/index.ts:10", icon: "typescript", label: "index.ts:10", + path: "apps/mobile/src/index.ts", + line: 10, }); expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ kind: "file", + href: "AGENTS.md", icon: "agents", label: "AGENTS.md", + path: "AGENTS.md", }); expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ kind: "file", + href: "package.json", icon: "package", label: "package.json", + path: "package.json", + }); + }); + + it("extracts line fragments from relative file links", () => { + expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ + kind: "file", + path: "src/main.ts", + line: 18, + column: 2, + label: "main.ts:18:2", }); }); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts new file mode 100644 index 00000000000..9a71640b45a --- /dev/null +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; + +import { buildModelOptions } from "./modelOptions"; + +describe("mobile model options", () => { + it("normalizes a legacy fallback selection against current capabilities", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], + }, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const [option] = buildModelOptions(config, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [{ id: "fastMode", value: true }], + }); + + expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); + expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 778e5bfb5b5..e21682414d7 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,4 +1,12 @@ -import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; +import type { + ModelCapabilities, + ModelSelection, + ServerConfig as T3ServerConfig, +} from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; export type ModelOption = { readonly key: string; @@ -7,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -27,6 +36,27 @@ function providerDisplayLabel(provider: { return provider.instanceId; } +function normalizeSelectionOptions( + selection: ModelSelection, + capabilities: ModelCapabilities | null, +): ModelSelection { + if (!capabilities) { + return selection; + } + const options = buildProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ + caps: capabilities, + selections: selection.options, + }), + ); + return options + ? { ...selection, options } + : { + instanceId: selection.instanceId, + model: selection.model, + }; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -48,17 +78,27 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, - selection: { - instanceId: provider.instanceId, - model: model.slug, - }, + capabilities: model.capabilities, + selection: normalizeSelectionOptions( + { + instanceId: provider.instanceId, + model: model.slug, + }, + model.capabilities, + ), }); } } if (fallbackModelSelection) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; - if (!options.has(key)) { + const existing = options.get(key); + if (existing) { + options.set(key, { + ...existing, + selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), + }); + } else { const providerLabel = fallbackModelSelection.instanceId; options.set(key, { key, @@ -67,6 +107,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + capabilities: null, selection: fallbackModelSelection, }); } diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 9d5c55686ec..6e41f2243a9 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -7,6 +7,7 @@ import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks, nativeMarkdownTextRuns, + nativeMarkdownWithPreservedSoftBreaks, } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { @@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => { externalHost: "example.com", }, { text: " " }, - { text: "README.md:12", fileIcon: "readme" }, + { + text: "README.md:12", + href: "file:///repo/README.md#L12", + fileIcon: "readme", + }, ]); }); @@ -73,6 +78,21 @@ describe("nativeMarkdownTextRuns", () => { expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); }); + it("can preserve soft breaks for authored user messages", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + ], + }; + + expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([ + { text: "first\nsecond" }, + ]); + }); + it("normalizes common inline HTML and entities", () => { const node: MarkdownNode = { type: "paragraph", @@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => { }); describe("nativeMarkdownDocumentRuns", () => { - it("decorates known skill references as selectable skill chips", () => { + it("decorates known skill references as selectable skill links", () => { const node: MarkdownNode = { type: "document", children: [ diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts new file mode 100644 index 00000000000..5a69cbdd43b --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,58 @@ +import { Linking } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { tryOpenExternalUrl } from "./openExternalUrl"; + +vi.mock("react-native", () => ({ + Linking: { openURL: vi.fn() }, +})); + +const openURL = vi.mocked(Linking.openURL); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tryOpenExternalUrl", () => { + it("opens supported URLs", async () => { + openURL.mockResolvedValue(undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code", "pull-request"), + ).resolves.toBe(true); + }); + + it("logs stable URL context without exposing the opening failure", async () => { + const cause = new Error("browser-unavailable-secret-sentinel"); + openURL.mockRejectedValue(cause); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"), + ).resolves.toBe(false); + + expect(consoleError).toHaveBeenCalledTimes(1); + const [message, attributes] = consoleError.mock.calls[0] ?? []; + expect(message).toBe("Failed to open pull-request URL with the https scheme."); + expect(attributes).toEqual( + expect.objectContaining({ + _tag: "ExternalUrlOpenError", + target: "pull-request", + scheme: "https", + host: "github.com", + stack: expect.stringContaining("ExternalUrlOpenError"), + }), + ); + expect(attributes).not.toHaveProperty("url"); + expect(attributes).not.toHaveProperty("cause"); + const diagnosticText = [message, ...Object.values(attributes as Record)] + .map(String) + .join("\n"); + expect(diagnosticText).not.toContain("token=secret"); + expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts new file mode 100644 index 00000000000..10e6378bc00 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -0,0 +1,51 @@ +import * as Schema from "effect/Schema"; +import { Linking } from "react-native"; + +const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); + +export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; + +export class ExternalUrlOpenError extends Schema.TaggedErrorClass()( + "ExternalUrlOpenError", + { + target: ExternalUrlTarget, + scheme: Schema.String, + host: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open ${this.target} URL with the ${this.scheme} scheme.`; + } +} + +function externalUrlMetadata(url: string): { readonly scheme: string; readonly host?: string } { + try { + const parsed = new URL(url); + return { + scheme: parsed.protocol.replace(/:$/, "") || "unknown", + host: parsed.hostname || undefined, + }; + } catch { + return { + scheme: /^([a-z][a-z\d+.-]*):/i.exec(url)?.[1]?.toLowerCase() ?? "unknown", + }; + } +} + +export async function tryOpenExternalUrl(url: string, target: ExternalUrlTarget): Promise { + try { + await Linking.openURL(url); + return true; + } catch (cause) { + const error = new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause }); + console.error(error.message, { + _tag: error._tag, + target: error.target, + scheme: error.scheme, + host: error.host, + stack: error.stack, + }); + return false; + } +} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts new file mode 100644 index 00000000000..d7f99a3dab7 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "./providerOptions"; + +const CODEX_CAPABILITIES: ModelCapabilities = { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], +}; + +describe("mobile provider options", () => { + it("renders the option descriptors advertised by the selected model", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Reasoning", + subtitle: "Medium", + subactions: [ + { title: "Medium (default)", state: "on" }, + { title: "High", state: undefined }, + ], + }, + { + title: "Service Tier", + subtitle: "Standard", + subactions: [ + { title: "Standard (default)", state: "on" }, + { title: "Fast", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + }); + + it("updates generic select options without knowing provider-specific ids", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const actions = buildProviderOptionMenuActions(descriptors); + const fastEvent = actions[1]?.subactions?.[1]?.id; + + expect(fastEvent).toBeDefined(); + expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + { id: "reasoningEffort", value: "medium" }, + { id: "serviceTier", value: "priority" }, + ]); + }); + + it("treats an unspecified boolean capability as off", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Fast Mode", + subtitle: "Off", + subactions: [ + { title: "Off", state: "on" }, + { title: "On", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); + }); +}); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts new file mode 100644 index 00000000000..ae195498962 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.ts @@ -0,0 +1,141 @@ +import type { + ModelCapabilities, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; + +const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; + +function providerOptionEvent(id: string, value: string | boolean): string { + return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; +} + +function parseProviderOptionEvent( + event: string, +): { readonly id: string; readonly value: string | boolean } | null { + if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { + return null; + } + + try { + const parsed: unknown = JSON.parse( + decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), + ); + if ( + typeof parsed === "object" && + parsed !== null && + "id" in parsed && + typeof parsed.id === "string" && + "value" in parsed && + (typeof parsed.value === "string" || typeof parsed.value === "boolean") + ) { + return { id: parsed.id, value: parsed.value }; + } + } catch { + return null; + } + + return null; +} + +export function resolveProviderOptionDescriptors(input: { + readonly capabilities: ModelCapabilities | null | undefined; + readonly selections: ReadonlyArray | null | undefined; +}): ReadonlyArray { + if (!input.capabilities) { + return []; + } + return getProviderOptionDescriptors({ + caps: input.capabilities, + selections: input.selections, + }); +} + +export function buildProviderOptionMenuActions( + descriptors: ReadonlyArray, +): ReadonlyArray { + return descriptors.map((descriptor) => { + const currentValue = + descriptor.type === "boolean" + ? (descriptor.currentValue ?? false) + : getProviderOptionCurrentValue(descriptor); + const choices = + descriptor.type === "select" + ? descriptor.options.map((option) => ({ + id: providerOptionEvent(descriptor.id, option.id), + title: `${option.label}${option.isDefault ? " (default)" : ""}`, + state: currentValue === option.id ? ("on" as const) : undefined, + })) + : ([false, true] as const).map((value) => ({ + id: providerOptionEvent(descriptor.id, value), + title: value ? "On" : "Off", + state: currentValue === value ? ("on" as const) : undefined, + })); + + return { + id: `provider-option-menu:${descriptor.id}`, + title: descriptor.label, + subtitle: + descriptor.type === "boolean" + ? currentValue + ? "On" + : "Off" + : getProviderOptionCurrentLabel(descriptor), + subactions: choices, + }; + }); +} + +export function providerOptionsConfigurationLabel( + descriptors: ReadonlyArray, +): string { + const labels = descriptors.flatMap((descriptor) => { + if (descriptor.type === "boolean") { + return descriptor.currentValue ? [descriptor.label] : []; + } + const label = getProviderOptionCurrentLabel(descriptor); + return label ? [label] : []; + }); + return labels.length > 0 ? labels.join(" · ") : "Configuration"; +} + +export function applyProviderOptionMenuEvent( + descriptors: ReadonlyArray, + event: string, +): ReadonlyArray | null { + const selection = parseProviderOptionEvent(event); + if (!selection) { + return null; + } + + const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + if (!descriptor) { + return null; + } + if ( + (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "select" && + (typeof selection.value !== "string" || + !descriptor.options.some((option) => option.id === selection.value))) + ) { + return null; + } + + const nextDescriptors = descriptors.map((candidate) => + candidate.id === descriptor.id + ? { + ...candidate, + currentValue: selection.value, + } + : candidate, + ) as ReadonlyArray; + + return buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? []; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 191afe03c18..8cea5df2307 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { groupProjectsByRepository } from "./repositoryGroups"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; function makeProject( - input: Partial & - Pick, -): EnvironmentScopedProjectShell { + input: Partial & Pick, +): EnvironmentProject { return { workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, @@ -24,12 +20,9 @@ function makeProject( } function makeThread( - input: Partial & - Pick< - EnvironmentScopedThreadShell, - "environmentId" | "id" | "projectId" | "title" | "modelSelection" - >, -): EnvironmentScopedThreadShell { + input: Partial & + Pick, +): EnvironmentThreadShell { return { runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts index 5238411a643..bf4c2f3fccd 100644 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ b/apps/mobile/src/lib/repositoryGroups.ts @@ -3,21 +3,18 @@ import * as Arr from "effect/Array"; import type { RepositoryIdentity } from "@t3tools/contracts"; import { scopedProjectKey } from "./scopedEntities"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const DateDescending = Order.flip(Order.Date); -export interface MobileRepositoryProjectGroup { +export interface RepositoryProjectGroup { readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; readonly latestActivityAt: string; } -export interface MobileRepositoryGroup { +export interface RepositoryGroup { readonly key: string; readonly title: string; readonly subtitle: string | null; @@ -25,20 +22,20 @@ export interface MobileRepositoryGroup { readonly projectCount: number; readonly threadCount: number; readonly latestActivityAt: string; - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; } function compareIsoDateDescending(left: string, right: string): number { return new Date(right).getTime() - new Date(left).getTime(); } -function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryGroupKey(project: EnvironmentProject): string { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) ); } -function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryTitle(project: EnvironmentProject): string { const identity = project.repositoryIdentity; return identity?.displayName ?? identity?.name ?? project.title; } @@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine } function deriveProjectLatestActivity( - project: EnvironmentScopedProjectShell, - threads: ReadonlyArray, + project: EnvironmentProject, + threads: ReadonlyArray, ): string { const latestThread = threads[0]; return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; } export function groupProjectsByRepository(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const threadsByProjectKey = new Map(); + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const threadsByProjectKey = new Map(); for (const thread of input.threads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); @@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: { } } - const grouped = new Map(); + const grouped = new Map(); for (const project of input.projects) { const key = deriveRepositoryGroupKey(project); @@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: { ); const latestActivityAt = deriveProjectLatestActivity(project, threads); - const projectGroup: MobileRepositoryProjectGroup = { + const projectGroup: RepositoryProjectGroup = { key: projectKey, project, threads, diff --git a/apps/mobile/src/lib/routes.test.ts b/apps/mobile/src/lib/routes.test.ts new file mode 100644 index 00000000000..773de9d84f7 --- /dev/null +++ b/apps/mobile/src/lib/routes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes"; + +const thread = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("thread file routes", () => { + it("includes an optional source line in string routes", () => { + expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe( + "/threads/environment-1/thread-1/files/src/main.ts?line=42", + ); + }); + + it("encodes each file path segment without encoding separators", () => { + expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe( + "/threads/environment-1/thread-1/files/docs/My%20File%231.md", + ); + }); + + it("builds typed navigation params for a file and source line", () => { + expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({ + pathname: "/threads/[environmentId]/[threadId]/files/[...path]", + params: { + environmentId: "environment-1", + threadId: "thread-1", + path: ["src", "main.ts"], + line: "42", + }, + }); + }); + + it("targets the files index when no file path is provided", () => { + expect(buildThreadFilesNavigation(thread)).toEqual({ + pathname: "/threads/[environmentId]/[threadId]/files", + params: { + environmentId: "environment-1", + threadId: "thread-1", + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..3a33e2ee0f9 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -1,5 +1,5 @@ import type { Href, useRouter } from "expo-router"; -import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SelectedThreadRef } from "../state/remote-runtime-types"; @@ -8,7 +8,7 @@ type Router = ReturnType; type ThreadRouteInput = | Pick - | Pick; + | Pick; type PlainThreadRouteInput = | { environmentId: EnvironmentId; @@ -32,6 +32,27 @@ export function buildThreadReviewRoutePath( return `${buildThreadRoutePath(input)}/review`; } +export function buildThreadFilesRoutePath( + input: ThreadRouteInput | PlainThreadRouteInput, + relativePath?: string | null, + line?: number | null, +): string { + const basePath = `${buildThreadRoutePath(input)}/files`; + if (!relativePath) { + return basePath; + } + + const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0); + if (pathSegments.length === 0) { + return basePath; + } + + const encodedPath = pathSegments.map(encodeURIComponent).join("/"); + const lineParam = + Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : ""; + return `${basePath}/${encodedPath}${lineParam}`; +} + export function buildThreadTerminalRoutePath( input: ThreadRouteInput | PlainThreadRouteInput, terminalId?: string | null, @@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation( }; } +export function buildThreadFilesNavigation( + input: ThreadRouteInput | PlainThreadRouteInput, + relativePath?: string | null, + line?: number | null, +): Href { + const environmentId = String(input.environmentId); + const threadId = String("threadId" in input ? input.threadId : input.id); + const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? []; + + if (path.length === 0) { + return { + pathname: "/threads/[environmentId]/[threadId]/files", + params: { environmentId, threadId }, + }; + } + + const params: { + environmentId: string; + threadId: string; + path: string[]; + line?: string; + } = { environmentId, threadId, path }; + if (Number.isFinite(line) && Number(line) > 0) { + params.line = String(Math.floor(Number(line))); + } + + return { + pathname: "/threads/[environmentId]/[threadId]/files/[...path]", + params, + }; +} + export function dismissRoute(router: Router) { if (router.canGoBack()) { router.back(); diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index ce37a41e8ab..51a4885562c 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,25 +1,42 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -import { mobileCryptoLayer } from "../features/cloud/dpop"; -import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; +import { cryptoLayer } from "../features/cloud/dpop"; +import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; -import { mobileTracingLayer } from "../features/observability/mobileTracing"; +import { tracingLayer } from "../features/observability/tracing"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; } -const mobileHttpClientLayer = remoteHttpClientLayer(fetch); +const httpClientLayer = remoteHttpClientLayer(fetch); -export const mobileRuntime = ManagedRuntime.make( - mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provideMerge(mobileCryptoLayer), - Layer.provideMerge(mobileHttpClientLayer), - Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), - ), +type RuntimeLayerSource = + | ReturnType + | typeof Socket.layerWebSocketConstructorGlobal + | typeof cryptoLayer + | typeof httpClientLayer + | typeof tracingLayer; + +const runtimeLayer = Layer.merge( + managedRelayClientLayer(configuredRelayUrl()), + Socket.layerWebSocketConstructorGlobal, +).pipe( + Layer.provideMerge(cryptoLayer), + Layer.provideMerge(httpClientLayer), + Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), ); -export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 83ff2db5748..084f9430d08 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -25,7 +25,7 @@ vi.mock("react-native", () => ({ })); vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); @@ -69,4 +69,35 @@ describe("mobile connection storage", () => { toStableSavedRemoteConnection(managedConnection), ]); }); + + it("preserves secure-storage read failures with operation and key context", async () => { + const cause = new Error("keychain unavailable"); + mocks.getItemAsync.mockRejectedValueOnce(cause); + + await expect(loadSavedConnections()).rejects.toMatchObject({ + _tag: "MobileSecureStorageError", + operation: "read", + key: "t3code.connections", + cause, + message: "Mobile secure storage operation read failed for key t3code.connections.", + }); + }); + + it("logs structured decode failures before using the empty fallback", async () => { + await mocks.setItemAsync("t3code.connections", "{"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadSavedConnections()).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] ignored invalid JSON", + expect.objectContaining({ + _tag: "MobileStorageDecodeError", + key: "t3code.connections", + cause: expect.any(SyntaxError), + message: "Failed to decode mobile storage value for key t3code.connections.", + }), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 2f9e4962c1a..114648277b9 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,9 +1,8 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; -import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, @@ -14,119 +13,96 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; - -export interface CachedShellSnapshot { - readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; - readonly environmentId: EnvironmentId; - readonly snapshotReceivedAt: string; - readonly snapshot: OrchestrationShellSnapshot; +const MobileStorageKey = Schema.Literals([ + CONNECTIONS_KEY, + PREFERENCES_KEY, + AGENT_AWARENESS_DEVICE_ID_KEY, +]); +type MobileStorageKeyValue = typeof MobileStorageKey.Type; + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: Schema.Literals(["read", "write", "generate-device-id"]), + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } } -export interface MobilePreferences { - readonly liveActivitiesEnabled?: boolean; - readonly terminalFontSize?: number; +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } } -const CachedShellSnapshotSchema = Schema.Struct({ - schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - snapshotReceivedAt: Schema.String, - snapshot: OrchestrationShellSnapshot, -}); -const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); - -async function readStorageItem(key: string): Promise { - return await SecureStore.getItemAsync(key); +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } } -async function writeStorageItem(key: string, value: string): Promise { - await SecureStore.setItemAsync(key, value); +export interface Preferences { + readonly liveActivitiesEnabled?: boolean; + readonly terminalFontSize?: number; } -async function readJsonStorageItem(key: string): Promise { - const raw = (await readStorageItem(key)) ?? ""; - if (!raw.trim()) { - return null; - } - +async function readStorageItem(key: MobileStorageKeyValue): Promise { try { - return JSON.parse(raw) as T; - } catch { - return null; + return await SecureStore.getItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "read", key, cause }); } } -function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -async function getShellSnapshotCacheDirectory() { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - return directory; +async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { + try { + await SecureStore.setItemAsync(key, value); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "write", key, cause }); + } } -export async function loadCachedShellSnapshot( - environmentId: EnvironmentId, -): Promise { - try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (!file.exists) { - return null; - } - - const parsed = JSON.parse(await file.text()) as unknown; - const decoded = decodeCachedShellSnapshot(parsed); - if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { - return null; - } - - return decoded.value; - } catch { +async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { + const raw = (await readStorageItem(key)) ?? ""; + if (!raw.trim()) { return null; } -} -export async function saveCachedShellSnapshot( - environmentId: EnvironmentId, - snapshot: OrchestrationShellSnapshot, -): Promise { try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - const document: CachedShellSnapshot = { - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshotReceivedAt: new Date().toISOString(), - snapshot, - }; - - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(document)); - } catch { - // Cache persistence is best-effort and should never block live data. + return JSON.parse(raw) as T; + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); + return null; } } -export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise { +async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { + let encoded: string; try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (file.exists) { - file.delete(); - } - } catch { - // Ignore cache cleanup failures. + encoded = JSON.stringify(value); + } catch (cause) { + throw new MobileStorageEncodeError({ key, cause }); } + await writeStorageItem(key, encoded); } export async function loadSavedConnections(): Promise> { @@ -157,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise ) : pipe(current, Arr.append(stableConnection)); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function clearSavedConnection(environmentId: EnvironmentId): Promise { @@ -166,11 +142,11 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis current, Arr.filter((entry) => entry.environmentId !== environmentId), ); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); +export async function loadPreferences(): Promise { + const parsed = await readJsonStorageItem(PREFERENCES_KEY); if (!parsed || typeof parsed !== "object") { return {}; } @@ -190,15 +166,13 @@ export async function loadPreferences(): Promise { return preferences; } -export async function savePreferencesPatch( - patch: Partial, -): Promise { +export async function savePreferencesPatch(patch: Partial): Promise { const current = await loadPreferences(); - const next: MobilePreferences = { + const next: Preferences = { ...current, ...patch, }; - await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } @@ -208,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise { return existing; } - const { uuidv4 } = await import("./uuid"); - const deviceId = uuidv4(); + const deviceId = await import("./uuid") + .then(({ uuidv4 }) => uuidv4()) + .catch((cause) => { + throw new MobileSecureStorageError({ + operation: "generate-device-id", + key: AGENT_AWARENESS_DEVICE_ID_KEY, + cause, + }); + }); await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); return deviceId; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index b500752c5d9..f5d8f4bdf11 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -161,14 +161,65 @@ describe("buildThreadFeed", () => { turnId: "turn-1", summary: "Run tests", detail: "bun run test", - fullDetail: null, - copyText: "Run tests\nbun run test", + fullDetail: "/bin/zsh -lc 'bun run test'", + copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", + icon: "command", toolLike: true, status: "success", }, ]); }); + it("keeps MCP inputs available to expanded mobile work rows", () => { + const turnId = TurnId.make("turn-mcp"); + const thread = makeThread({ + id: ThreadId.make("thread-mcp"), + projectId: ProjectId.make("project-1"), + title: "Expandable MCP call", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("mcp-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Call repository tool", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + title: "Call repository tool", + itemType: "mcp_tool_call", + detail: "repository.search", + status: "completed", + data: { + item: { + server: "repository", + tool: "search", + arguments: { query: "work log" }, + }, + }, + }, + }), + ], + }); + + const group = buildThreadFeed(thread, [], 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({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e5fdb439954..bef46e46e6e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,19 +1,16 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - CommandId, - EnvironmentId, MessageId, OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, ToolLifecycleItemType, - ThreadId, TurnId, UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import type { QueuedThreadMessage } from "../state/thread-outbox-model"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -35,16 +32,6 @@ export interface PendingUserInputDraftAnswer { readonly customAnswer?: string; } -export interface QueuedThreadMessage { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly messageId: MessageId; - readonly commandId: CommandId; - readonly text: string; - readonly attachments: ReadonlyArray; - readonly createdAt: string; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -53,6 +40,19 @@ export interface ThreadFeedActivity { readonly detail: string | null; readonly fullDetail: string | null; readonly copyText: string; + readonly icon: + | "agent" + | "alert" + | "check" + | "command" + | "edit" + | "eye" + | "globe" + | "hammer" + | "message" + | "warning" + | "wrench" + | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; } @@ -73,6 +73,7 @@ interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; + toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -227,7 +228,7 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, -): WorkLogEntry[] { +): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { @@ -238,9 +239,7 @@ function deriveWorkLogEntries( if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } - return collapseDerivedWorkLogEntries(entries).map( - ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, - ); + return collapseDerivedWorkLogEntries(entries); } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { @@ -314,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } + if (itemType === "mcp_tool_call") { + const data = asRecord(payload?.data); + if (data?.item !== undefined) { + entry.toolData = data.item; + } + } if (itemType) { entry.itemType = itemType; } @@ -378,6 +383,7 @@ function mergeDerivedWorkLogEntries( const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; + const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, @@ -390,6 +396,7 @@ function mergeDerivedWorkLogEntries( ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } @@ -493,6 +500,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { return "neutral"; } +function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { + if ( + entry.activityKind === "user-input.requested" || + entry.activityKind === "user-input.resolved" + ) { + return "message"; + } + if (entry.activityKind === "runtime.warning") return "warning"; + if (entry.requestKind === "command") return "command"; + if (entry.requestKind === "file-read") return "eye"; + if (entry.requestKind === "file-change") return "edit"; + if (entry.itemType === "command_execution" || entry.command) return "command"; + if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; + if (entry.itemType === "web_search") return "globe"; + if (entry.itemType === "image_view") return "eye"; + if (entry.itemType === "mcp_tool_call") return "wrench"; + if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { + return "hammer"; + } + if (entry.tone === "error") return "alert"; + if (entry.tone === "thinking") return "agent"; + if (entry.tone === "info") return "check"; + return "zap"; +} + +function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { + const blocks: string[] = []; + const appendUniqueBlock = (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (trimmed && !blocks.includes(trimmed)) { + blocks.push(trimmed); + } + }; + + if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { + appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); + } + appendUniqueBlock(entry.rawCommand ?? entry.command); + appendUniqueBlock(entry.detail); + if ((entry.changedFiles?.length ?? 0) > 0) { + appendUniqueBlock(entry.changedFiles!.join("\n")); + } + + return blocks.length > 0 ? blocks.join("\n\n") : null; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -1239,11 +1292,7 @@ export function buildThreadFeed( .map((entry) => { const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); - const normalizedFullDetail = entry.detail - ? unwrapKnownShellCommandWrapper(entry.detail) - : null; - const fullDetail = - normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; + const fullDetail = buildWorkEntryExpandedBody(entry); return { type: "activity", id: entry.id, @@ -1256,6 +1305,7 @@ export function buildThreadFeed( summary, detail, fullDetail, + icon: workEntryIcon(entry), copyText: [summary, detail, fullDetail] .filter((value, index, values): value is string => { return Boolean(value) && values.indexOf(value) === index; diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts new file mode 100644 index 00000000000..6a021dabcce --- /dev/null +++ b/apps/mobile/src/lib/typography.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; + +describe("mobile typography", () => { + it("uses the intentional compact mobile font scale", () => { + expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ + 10, 11, 12, 13, 14, 15, 17, 20, 24, 28, + ]); + }); + + it("uses a compact shared style for editable composer text", () => { + expect(MOBILE_TYPOGRAPHY.composer).toEqual({ fontSize: 14, lineHeight: 20 }); + }); + + it("uses caption-sized code with a compact readable row height", () => { + expect(MOBILE_CODE_SURFACE).toMatchObject({ + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, + lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, + rowHeight: 20, + }); + }); +}); diff --git a/apps/mobile/src/lib/typography.ts b/apps/mobile/src/lib/typography.ts new file mode 100644 index 00000000000..644fee36589 --- /dev/null +++ b/apps/mobile/src/lib/typography.ts @@ -0,0 +1,22 @@ +export const MOBILE_TYPOGRAPHY = { + micro: { fontSize: 10, lineHeight: 13 }, + caption: { fontSize: 11, lineHeight: 15 }, + label: { fontSize: 12, lineHeight: 16 }, + footnote: { fontSize: 13, lineHeight: 18 }, + composer: { fontSize: 14, lineHeight: 20 }, + body: { fontSize: 15, lineHeight: 22 }, + headline: { fontSize: 17, lineHeight: 22 }, + title: { fontSize: 20, lineHeight: 26 }, + largeTitle: { fontSize: 24, lineHeight: 30 }, + display: { fontSize: 28, lineHeight: 34 }, +} as const; + +/** Shared geometry for dense, horizontally scrolling code surfaces. */ +export const MOBILE_CODE_SURFACE = { + rowHeight: 20, + gutterWidth: 46, + codePadding: 7, + textVerticalInset: 2, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, + lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, +} as const; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 6778b0455d5..7dd92ff067f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -6,6 +6,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; @@ -150,9 +151,15 @@ export function ComposerEditor({ ? resolvedTextStyle.fontFamily : "DMSans_400Regular" } - fontSize={typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize : 15} + fontSize={ + typeof resolvedTextStyle.fontSize === "number" + ? resolvedTextStyle.fontSize + : MOBILE_TYPOGRAPHY.composer.fontSize + } lineHeight={ - typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight : 22 + typeof resolvedTextStyle.lineHeight === "number" + ? resolvedTextStyle.lineHeight + : MOBILE_TYPOGRAPHY.composer.lineHeight } contentInsetVertical={contentInsetVertical} editable={props.editable ?? true} diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 0f20e9e042d..dc2dfdfee03 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,6 +2,7 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; import { useNativePaste } from "../lib/useNativePaste"; import type { ComposerEditorProps } from "./T3ComposerEditor.types"; @@ -47,8 +48,7 @@ export function ComposerEditor({ minHeight: 0, color: foregroundColor, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.composer, paddingVertical: contentInsetVertical, }, textStyle, diff --git a/apps/mobile/src/native/nativeViewResolutionError.ts b/apps/mobile/src/native/nativeViewResolutionError.ts new file mode 100644 index 00000000000..bfcf8351a66 --- /dev/null +++ b/apps/mobile/src/native/nativeViewResolutionError.ts @@ -0,0 +1,13 @@ +import * as Schema from "effect/Schema"; + +export class NativeViewResolutionError extends Schema.TaggedErrorClass()( + "NativeViewResolutionError", + { + nativeModuleName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve native view ${this.nativeModuleName}.`; + } +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts new file mode 100644 index 00000000000..b8b827585ea --- /dev/null +++ b/apps/mobile/src/state/assets.ts @@ -0,0 +1,29 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { usePreparedConnection } from "./session"; + +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-asset-url:empty"), +); + +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { + const preparedConnection = usePreparedConnection(environmentId); + const result = useAtomValue( + environmentId === null || resource === null + ? EMPTY_ASSET_URL_ATOM + : assetEnvironment.createUrl({ environmentId, input: { resource } }), + ); + if (preparedConnection._tag === "None" || result._tag !== "Success") { + return null; + } + return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); +} diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/mobile/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts new file mode 100644 index 00000000000..8199dee3486 --- /dev/null +++ b/apps/mobile/src/state/entities.ts @@ -0,0 +1,58 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { + EnvironmentId, + ScopedProjectRef, + ScopedThreadRef, + ServerConfig, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; +import { environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-project:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread-shell:empty"), +); +const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-server-config:empty"), +); + +export function useProjects(): ReadonlyArray { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useThreadShells(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useEnvironmentServerConfig( + environmentId: EnvironmentId | null, +): ServerConfig | null { + return useAtomValue( + environmentId === null + ? EMPTY_SERVER_CONFIG_ATOM + : serverEnvironment.configValueAtom(environmentId), + ); +} + +export function useServerConfigs(): ReadonlyMap { + return useAtomValue(environmentServerConfigsAtom); +} diff --git a/apps/mobile/src/state/environment-session-registry.ts b/apps/mobile/src/state/environment-session-registry.ts deleted file mode 100644 index 3eb94b32c06..00000000000 --- a/apps/mobile/src/state/environment-session-registry.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -import type { EnvironmentSession } from "./remote-runtime-types"; - -const environmentSessions = new Map(); -const environmentConnectionListeners = new Set<() => void>(); - -export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - return environmentSessions.get(environmentId) ?? null; -} - -export function getEnvironmentClient(environmentId: EnvironmentId) { - return getEnvironmentSession(environmentId)?.client ?? null; -} - -export function setEnvironmentSession( - environmentId: EnvironmentId, - session: EnvironmentSession, -): void { - environmentSessions.set(environmentId, session); -} - -export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - const session = getEnvironmentSession(environmentId); - environmentSessions.delete(environmentId); - return session; -} - -export function drainEnvironmentSessions(): ReadonlyArray { - const sessions = [...environmentSessions.values()]; - environmentSessions.clear(); - return sessions; -} - -export function notifyEnvironmentConnectionListeners() { - for (const listener of environmentConnectionListeners) listener(); -} - -/** - * Subscribe to environment-connection changes (connect / disconnect / reconnect). - * Returns an unsubscribe function. - */ -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts new file mode 100644 index 00000000000..88d80631ad3 --- /dev/null +++ b/apps/mobile/src/state/environments.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "./presentation"; +import { useEnvironmentQuery } from "./query"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/mobile/src/state/filesystem.ts b/apps/mobile/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/mobile/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/mobile/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/mobile/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts new file mode 100644 index 00000000000..96171d3ea5c --- /dev/null +++ b/apps/mobile/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { serverEnvironment } from "./server"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/mobile/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts new file mode 100644 index 00000000000..68c23202308 --- /dev/null +++ b/apps/mobile/src/state/queries.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets"; + +describe("appQueries", () => { + it("normalizes composer path search input", () => { + expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app"); + expect(normalizeComposerPathSearchQuery(null)).toBe(""); + }); + + it("routes the first turn range through the full-thread diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 0, + toTurnCount: 4, + ignoreWhitespace: true, + }), + ).toEqual({ + fullThread: { + environmentId, + input: { + threadId, + toTurnCount: 4, + ignoreWhitespace: true, + }, + }, + turn: null, + }); + }); + + it("routes later ranges through the incremental turn diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }), + ).toEqual({ + fullThread: null, + turn: { + environmentId, + input: { + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts new file mode 100644 index 00000000000..ea625995928 --- /dev/null +++ b/apps/mobile/src/state/queries.ts @@ -0,0 +1,134 @@ +import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useMemo, useState } from "react"; + +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; +import { + buildCheckpointDiffTargets, + normalizeComposerPathSearchQuery, + type CheckpointDiffTarget, +} from "./queryTargets"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; +const COMPOSER_PATH_SEARCH_LIMIT = 20; +const VCS_REF_LIST_LIMIT = 100; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} + +function useDebouncedValue(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +}) { + const query = input.query?.trim() ?? ""; + return useEnvironmentQuery( + input.environmentId !== null && input.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: input.environmentId, + input: { + cwd: input.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: normalizeComposerPathSearchQuery(target.query), + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff(target: CheckpointDiffTarget) { + const targets = useMemo( + () => buildCheckpointDiffTargets(target), + [ + target.environmentId, + target.fromTurnCount, + target.ignoreWhitespace, + target.threadId, + target.toTurnCount, + ], + ); + const fullThread = useEnvironmentQuery( + targets.fullThread === null + ? null + : orchestrationEnvironment.fullThreadDiff(targets.fullThread), + ); + const turn = useEnvironmentQuery( + targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn), + ); + return targets.fullThread === null ? turn : fullThread; +} diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts new file mode 100644 index 00000000000..c29d01d397b --- /dev/null +++ b/apps/mobile/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-environment-query:empty"), +); + +export interface EnvironmentQueryView { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery( + atom: Atom.Atom> | null, +): EnvironmentQueryView { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts new file mode 100644 index 00000000000..a52da3fc134 --- /dev/null +++ b/apps/mobile/src/state/queryTargets.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; +} + +export function normalizeComposerPathSearchQuery(query: string | null): string { + return query?.trim() ?? ""; +} + +export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { + if ( + target.environmentId === null || + target.threadId === null || + target.fromTurnCount === null || + target.toTurnCount === null + ) { + return { fullThread: null, turn: null } as const; + } + + if (target.fromTurnCount === 0) { + return { + fullThread: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + turn: null, + } as const; + } + + return { + fullThread: null, + turn: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + fromTurnCount: target.fromTurnCount, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + } as const; +} diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts new file mode 100644 index 00000000000..f078572736b --- /dev/null +++ b/apps/mobile/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 054203715bd..89abd3c222e 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -1,27 +1,24 @@ -import type { - EnvironmentConnection, - EnvironmentConnectionState, - WsRpcClient, -} from "@t3tools/client-runtime"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts"; -export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; +export interface EnvironmentRuntimeState { + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly serverConfig: ServerConfig | null; +} export interface ConnectedEnvironmentSummary { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly displayUrl: string; readonly isRelayManaged: boolean; - readonly connectionState: EnvironmentConnectionState; + readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; } export interface SelectedThreadRef { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; } - -export interface EnvironmentSession { - readonly client: WsRpcClient; - readonly connection: EnvironmentConnection; -} diff --git a/apps/mobile/src/state/review.ts b/apps/mobile/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/mobile/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts new file mode 100644 index 00000000000..1b7060571a5 --- /dev/null +++ b/apps/mobile/src/state/server.ts @@ -0,0 +1,14 @@ +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { + initialConfigValueAtom: environmentSession.initialConfigValueAtom, +}); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, +}); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts new file mode 100644 index 00000000000..747ab7c72ee --- /dev/null +++ b/apps/mobile/src/state/session.ts @@ -0,0 +1,21 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("mobile-prepared-connection:empty"), +); + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/mobile/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/mobile/src/state/sourceControl.ts b/apps/mobile/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/mobile/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/terminal.ts b/apps/mobile/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/mobile/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts new file mode 100644 index 00000000000..477cb1273a3 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -0,0 +1,108 @@ +import type { EnvironmentId, MessageId } from "@t3tools/contracts"; +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 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((error) => { + loadPromise = null; + warn("[thread-outbox] failed to load persisted messages", error); + }); + return loadPromise; + }; + + const enqueue = (message: QueuedThreadMessage): Promise => + serialize(async () => { + await options.storage.write(message); + setMessages([...currentMessages(), message]); + }); + + const remove = (message: QueuedThreadMessage): Promise => + serialize(async () => { + await options.storage.remove(message); + setMessages( + currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + ); + }); + + const clearEnvironment = (environmentId: EnvironmentId): Promise => + serialize(async () => { + const persisted = await options.storage.load().catch((error) => { + warn("[thread-outbox] failed to load messages while clearing environment", error); + 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 (error) { + warn("[thread-outbox] failed to clear persisted message", error); + } + }), + ); + + setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId))); + }); + + return { + queuedMessagesByThreadKeyAtom, + serialize, + load, + enqueue, + remove, + clearEnvironment, + }; +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts new file mode 100644 index 00000000000..aa7a1055136 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -0,0 +1,121 @@ +import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { scopedThreadKey } from "../lib/scopedEntities"; + +const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; + +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, +}); + +export const QueuedThreadMessageSchema = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + commandId: CommandId, + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + 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 createdAt: string; +} + +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)); +} diff --git a/apps/mobile/src/state/thread-outbox-storage.ts b/apps/mobile/src/state/thread-outbox-storage.ts new file mode 100644 index 00000000000..e294aee4549 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-storage.ts @@ -0,0 +1,64 @@ +import type { MessageId } from "@t3tools/contracts"; + +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + type QueuedThreadMessage, +} from "./thread-outbox-model"; + +const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; + +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 { File } = await import("expo-file-system"); + const directory = await getOutboxDirectory(); + const messages: QueuedThreadMessage[] = []; + + 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 (error) { + console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error); + } + } + return messages; + }, + write: async (message) => { + const file = await getMessageFile(message.messageId); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encodeQueuedThreadMessage(message))); + }, + remove: async (message) => { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + }, +}; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts new file mode 100644 index 00000000000..d2634fb966f --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/unstable/reactivity"; + +import { + decodeQueuedThreadMessage, + groupQueuedThreadMessages, + resolveThreadOutboxDeliveryAction, + shouldRetryThreadOutboxDelivery, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, +} from "./thread-outbox-model"; +import { createThreadOutboxManager } 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("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("keeps atom state aligned with durable writes and removals", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + let failRemoval = true; + const storage: ThreadOutboxStorage = { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + if (failRemoval) { + throw new Error("remove failed"); + } + 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.toThrow("remove failed"); + 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); + }); +}); diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts new file mode 100644 index 00000000000..d5eb383a0e9 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.ts @@ -0,0 +1,29 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { appAtomRegistry } from "./atom-registry"; +import { createThreadOutboxManager } from "./thread-outbox-manager"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { expoThreadOutboxStorage } from "./thread-outbox-storage"; + +export * from "./thread-outbox-model"; + +export const threadOutboxManager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: expoThreadOutboxStorage, +}); + +export function ensureThreadOutboxLoaded(): void { + void threadOutboxManager.load(); +} + +export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise { + return threadOutboxManager.enqueue(message); +} + +export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise { + return threadOutboxManager.remove(message); +} + +export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { + return threadOutboxManager.clearEnvironment(environmentId); +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts new file mode 100644 index 00000000000..7f247123051 --- /dev/null +++ b/apps/mobile/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("mobile-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/mobile/src/state/use-atom-command.ts b/apps/mobile/src/state/use-atom-command.ts new file mode 100644 index 00000000000..37ce280e9f4 --- /dev/null +++ b/apps/mobile/src/state/use-atom-command.ts @@ -0,0 +1,23 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + type AtomCommand, + type AtomCommandOptions, + type AtomCommandResult, + runAtomCommand, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useContext } from "react"; + +export function useAtomCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const registry = useContext(RegistryContext); + const label = typeof options === "string" ? options : (options?.label ?? command.label); + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), + [command, label, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts new file mode 100644 index 00000000000..22f971e09a5 --- /dev/null +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -0,0 +1,30 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + executeAtomQuery, + type AtomCommandOptions, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext } from "react"; + +export function useAtomQueryRunner( + family: (target: T) => Atom.Atom>, + options?: string | AtomCommandOptions, +): (target: T) => Promise> { + const registry = useContext(RegistryContext); + const explicitLabel = typeof options === "string" ? options : options?.label; + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (target: T) => { + const atom = family(target); + return executeAtomQuery(registry, atom, { + label: explicitLabel ?? atom.label?.[0] ?? "atom query", + reportFailure, + reportDefect, + }); + }, + [explicitLabel, family, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/mobile/src/state/use-checkpoint-diff.ts b/apps/mobile/src/state/use-checkpoint-diff.ts deleted file mode 100644 index 3111008f00a..00000000000 --- a/apps/mobile/src/state/use-checkpoint-diff.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; - -export const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, -}); - -export function loadCheckpointDiff( - target: CheckpointDiffTarget, - options?: { readonly force?: boolean }, -) { - return checkpointDiffManager.load(target, undefined, options); -} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts new file mode 100644 index 00000000000..48e4e8703f0 --- /dev/null +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; + +const DRAFT: ComposerDraft = { + text: "hello", + attachments: [], +}; + +describe("mobile composer drafts", () => { + 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, + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + }, + environmentId, + ), + ).toEqual({ + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + }); + }); +}); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6ac9786ad0e..d0329ad2598 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,4 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; @@ -10,6 +12,20 @@ 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; @@ -30,7 +46,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 { @@ -55,12 +71,16 @@ 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; + operation = "read"; + const raw = await file.text(); + operation = "decode"; + const parsed = JSON.parse(raw) as Partial; if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { return {}; } @@ -74,14 +94,25 @@ async function loadPersistedComposerDrafts(): Promise): 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)), ); @@ -89,11 +120,27 @@ async function savePersistedComposerDrafts(drafts: Record schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, }; + 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 +156,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( @@ -234,6 +293,35 @@ export function clearComposerDraft(draftKey: string): void { }); } +export function removeComposerDraftsForEnvironment( + drafts: Record, + environmentId: EnvironmentId, +): Record { + const environmentPrefix = `${environmentId}:`; + return Object.fromEntries( + Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + ); +} + +export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + + const next = removeComposerDraftsForEnvironment( + appAtomRegistry.get(composerDraftsAtom), + environmentId, + ); + + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + appAtomRegistry.set(composerDraftsAtom, next); + await writePersistedComposerDrafts(next); +} + export function useComposerDraft(draftKey: string | null): ComposerDraft { const drafts = useAtomValue(composerDraftsAtom); useEffect(() => { diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts index a42143a427b..485b472dcb0 100644 --- a/apps/mobile/src/state/use-composer-path-search.ts +++ b/apps/mobile/src/state/use-composer-path-search.ts @@ -1,46 +1,7 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type ComposerPathSearchState, - type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useComposerPathSearchQuery(target); } diff --git a/apps/mobile/src/state/use-environment-runtime.ts b/apps/mobile/src/state/use-environment-runtime.ts deleted file mode 100644 index f4a65a0d283..00000000000 --- a/apps/mobile/src/state/use-environment-runtime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_ENVIRONMENT_RUNTIME_ATOM, - EMPTY_ENVIRONMENT_RUNTIME_STATE, - createEnvironmentRuntimeManager, - environmentRuntimeStateAtom, - getEnvironmentRuntimeTargetKey, - type EnvironmentRuntimeState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; - -export const environmentRuntimeManager = createEnvironmentRuntimeManager({ - getRegistry: () => appAtomRegistry, -}); - -export function useEnvironmentRuntime( - environmentId: EnvironmentId | null, -): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, - ); - return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; -} - -export function useEnvironmentRuntimeStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-filesystem-browse.ts b/apps/mobile/src/state/use-filesystem-browse.ts deleted file mode 100644 index e5ab77a80af..00000000000 --- a/apps/mobile/src/state/use-filesystem-browse.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_FILESYSTEM_BROWSE_ATOM, - EMPTY_FILESYSTEM_BROWSE_STATE, - type FilesystemBrowseClient, - type FilesystemBrowseState, - type FilesystemBrowseTarget, - createFilesystemBrowseManager, - filesystemBrowseStateAtom, - getFilesystemBrowseTargetKey, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - FilesystemBrowseInput, - FilesystemBrowseResult, -} from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const filesystemBrowseManager = createFilesystemBrowseManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function filesystemBrowseTargetForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseTarget { - return { key: environmentId, input }; -} - -export function refreshFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, - client?: FilesystemBrowseClient | null, -): Promise { - return filesystemBrowseManager.refresh( - filesystemBrowseTargetForEnvironment(environmentId, input), - client ?? undefined, - ); -} - -export function invalidateFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): void { - filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input)); -} - -export function resetFilesystemBrowseState(): void { - filesystemBrowseManager.reset(); -} - -export function resetFilesystemBrowseStateForTests(): void { - resetFilesystemBrowseState(); -} - -export function useFilesystemBrowse( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseState { - const target = useMemo( - () => filesystemBrowseTargetForEnvironment(environmentId, input), - [environmentId, input], - ); - - useEffect(() => { - return filesystemBrowseManager.watch(target); - }, [target]); - - const targetKey = getFilesystemBrowseTargetKey(target); - const state = useAtomValue( - targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM, - ); - return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state; -} diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts deleted file mode 100644 index 8a5ddac2c0f..00000000000 --- a/apps/mobile/src/state/use-remote-catalog.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useMemo } from "react"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; - -import { - EnvironmentConnectionState, - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - scopeProjectShell, - scopeThreadShell, -} from "@t3tools/client-runtime"; - -import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import type { SavedRemoteConnection } from "../lib/connection"; -import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot"; -import { - useRemoteConnectionStatus, - useRemoteEnvironmentState, -} from "./use-remote-environment-registry"; - -const projectsSortOrder = Order.mapInput( - Order.Struct({ - title: Order.String, - environmentId: Order.String, - }), - (project: EnvironmentScopedProjectShell) => ({ - title: project.title, - environmentId: project.environmentId, - }), -); - -const threadsSortOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.String), - environmentId: Order.String, - }), - (thread: EnvironmentScopedThreadShell) => ({ - activityAt: thread.updatedAt ?? thread.createdAt, - environmentId: thread.environmentId, - }), -); - -function deriveOverallConnectionState( - environments: ReadonlyArray, -): EnvironmentConnectionState { - if (environments.length === 0) { - return "idle"; - } - if (environments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if (environments.some((environment) => environment.connectionState === "reconnecting")) { - return "reconnecting"; - } - if (environments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; -} - -function listRemoteCatalogEnvironmentIds( - savedConnectionsById: Readonly>, -): ReadonlyArray { - const environmentIds: SavedRemoteConnection["environmentId"][] = []; - for (const connection of Object.values(savedConnectionsById)) { - environmentIds.push(connection.environmentId); - } - return environmentIds; -} - -export interface RemoteCatalogState { - readonly isLoadingSavedConnections: boolean; - readonly hasSavedConnections: boolean; - readonly hasLoadedShellSnapshot: boolean; - readonly hasPendingShellSnapshot: boolean; - readonly hasReadyEnvironment: boolean; - readonly hasConnectingEnvironment: boolean; - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly shellSnapshotError: string | null; - readonly isUsingCachedData: boolean; - readonly latestCachedSnapshotReceivedAt: string | null; -} - -export function useRemoteCatalog() { - const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); - const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } = - useRemoteEnvironmentState(); - const catalogEnvironmentIds = useMemo( - () => listRemoteCatalogEnvironmentIds(savedConnectionsById), - [savedConnectionsById], - ); - const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds); - const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata(); - - const projects = useMemo(() => { - const scopedProjects: EnvironmentScopedProjectShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? []; - for (const project of projects) { - scopedProjects.push(scopeProjectShell(connection.environmentId, project)); - } - } - return Arr.sort(scopedProjects, projectsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const threads = useMemo(() => { - const scopedThreads: EnvironmentScopedThreadShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? []; - for (const thread of threads) { - scopedThreads.push(scopeThreadShell(connection.environmentId, thread)); - } - } - return Arr.sort(scopedThreads, threadsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const serverConfigByEnvironmentId = useMemo( - () => - Object.fromEntries( - Object.entries(environmentStateById).map(([environmentId, runtime]) => [ - environmentId, - runtime.serverConfig ?? null, - ]), - ), - [environmentStateById], - ); - - const overallConnectionState = useMemo( - () => deriveOverallConnectionState(connectedEnvironments), - [connectedEnvironments], - ); - - const hasRemoteActivity = useMemo( - () => - threads.some( - (thread) => thread.session?.status === "running" || thread.session?.status === "starting", - ), - [threads], - ); - - const state = useMemo(() => { - const shellSnapshots = Object.values(shellSnapshotStates); - const cachedSnapshotReceivedAts: string[] = []; - for (const environmentId of catalogEnvironmentIds) { - const metadata = cachedShellSnapshotMetadata[environmentId]; - if (metadata) { - cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt); - } - } - let shellSnapshotError: string | null = null; - for (const snapshot of shellSnapshots) { - if (snapshot.error !== null) { - shellSnapshotError = snapshot.error; - break; - } - } - return { - isLoadingSavedConnections: isLoadingSavedConnection, - hasSavedConnections: catalogEnvironmentIds.length > 0, - hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null), - hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending), - hasReadyEnvironment: connectedEnvironments.some( - (environment) => environment.connectionState === "ready", - ), - hasConnectingEnvironment: connectedEnvironments.some( - (environment) => - environment.connectionState === "connecting" || - environment.connectionState === "reconnecting", - ), - connectionState: connectionState ?? overallConnectionState, - connectionError, - shellSnapshotError, - isUsingCachedData: cachedSnapshotReceivedAts.length > 0, - latestCachedSnapshotReceivedAt: - Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null, - }; - }, [ - cachedShellSnapshotMetadata, - catalogEnvironmentIds, - connectedEnvironments, - connectionError, - connectionState, - isLoadingSavedConnection, - overallConnectionState, - shellSnapshotStates, - ]); - - return { - projects, - threads, - serverConfigByEnvironmentId, - connectionState: state.connectionState, - connectionError: state.connectionError, - state, - hasRemoteActivity, - }; -} diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts deleted file mode 100644 index fc465bbfb88..00000000000 --- a/apps/mobile/src/state/use-remote-environment-registry.test.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; -import { - createManagedRelaySession, - ManagedRelayDpopSigner, - setManagedRelaySession, -} from "@t3tools/client-runtime"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => { - const environmentConnection = { - ensureBootstrapped: vi.fn(() => Promise.resolve()), - dispose: vi.fn(() => Promise.resolve()), - }; - const sessionConnection = { - dispose: vi.fn(() => Promise.resolve()), - reconnect: vi.fn(() => Promise.resolve()), - }; - const sessionClient = { - isHeartbeatFresh: vi.fn(() => false), - }; - return { - environmentConnection, - sessionConnection, - sessionClient, - createEnvironmentConnection: vi.fn(() => environmentConnection), - createKnownEnvironment: vi.fn((input: unknown) => input), - createWsRpcClient: vi.fn(() => ({ rpc: true })), - wsTransportConstructor: vi.fn(), - resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), - resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), - remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), - createDpopProof: vi.fn(), - refreshCloudEnvironmentConnection: vi.fn(), - bootstrapRemoteConnection: vi.fn(), - clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), - clearSavedConnection: vi.fn(() => Promise.resolve()), - saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), - saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), - mobileRunPromise: vi.fn((_effect?: unknown) => - Promise.resolve("wss://desktop.example/ws?wsTicket=token"), - ), - removeEnvironmentSession: vi.fn(() => null), - getEnvironmentSession: vi.fn(() => null), - setEnvironmentSession: vi.fn(), - notifyEnvironmentConnectionListeners: vi.fn(), - unregisterAgentAwarenessConnection: vi.fn(), - registerAgentAwarenessConnection: vi.fn(), - shellSnapshotInvalidate: vi.fn(), - shellSnapshotMarkPending: vi.fn(), - environmentRuntimeInvalidate: vi.fn(), - environmentRuntimePatch: vi.fn(), - clearCachedShellSnapshotMetadata: vi.fn(), - invalidateSourceControlDiscoveryForEnvironment: vi.fn(), - terminalSessionInvalidateEnvironment: vi.fn(), - subscribeTerminalMetadata: vi.fn(() => vi.fn()), - terminalDebugLog: vi.fn(), - WsTransport: function WsTransport(...args: ReadonlyArray) { - mocks.wsTransportConstructor(...args); - }, - }; -}); - -vi.mock("react-native", () => ({ - Alert: { - alert: vi.fn(), - }, - AppState: { - currentState: "active", - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - }, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - WsTransport: mocks.WsTransport, - createEnvironmentConnection: mocks.createEnvironmentConnection, - createKnownEnvironment: mocks.createKnownEnvironment, - createWsRpcClient: mocks.createWsRpcClient, - remoteEndpointUrl: mocks.remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../lib/connection", async (importOriginal) => ({ - ...(await importOriginal()), - bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, -})); - -vi.mock("../features/cloud/linkEnvironment", () => ({ - refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, -})); - -vi.mock("../lib/storage", () => ({ - clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, - clearSavedConnection: mocks.clearSavedConnection, - loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), - loadSavedConnections: vi.fn(() => Promise.resolve([])), - saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, - saveConnection: mocks.saveConnection, -})); - -vi.mock("../lib/runtime", () => ({ - mobileRuntime: { - runPromise: mocks.mobileRunPromise, - }, -})); - -vi.mock("./environment-session-registry", () => ({ - drainEnvironmentSessions: vi.fn(() => []), - getEnvironmentSession: mocks.getEnvironmentSession, - notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, - removeEnvironmentSession: mocks.removeEnvironmentSession, - setEnvironmentSession: mocks.setEnvironmentSession, -})); - -vi.mock("../features/agent-awareness/remoteRegistration", () => ({ - registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections: vi.fn(), -})); - -vi.mock("../features/terminal/terminalDebugLog", () => ({ - terminalDebugLog: mocks.terminalDebugLog, -})); - -vi.mock("./use-environment-runtime", () => ({ - environmentRuntimeManager: { - invalidate: mocks.environmentRuntimeInvalidate, - patch: mocks.environmentRuntimePatch, - }, - useEnvironmentRuntimeStates: vi.fn(() => ({})), -})); - -vi.mock("./use-shell-snapshot", () => ({ - clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot: vi.fn(), - markShellSnapshotLive: vi.fn(), - shellSnapshotManager: { - applyEvent: vi.fn(), - invalidate: mocks.shellSnapshotInvalidate, - markPending: mocks.shellSnapshotMarkPending, - syncSnapshot: vi.fn(), - }, -})); - -vi.mock("./use-source-control-discovery", () => ({ - invalidateSourceControlDiscoveryForEnvironment: - mocks.invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState: vi.fn(), -})); - -vi.mock("./use-terminal-session", () => ({ - subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, - terminalSessionManager: { - invalidate: vi.fn(), - invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, - }, -})); - -import { - connectSavedEnvironment, - disconnectEnvironment, - reconnectEnvironmentConnectionsAfterAppResume, -} from "./use-remote-environment-registry"; -import { appAtomRegistry } from "./atom-registry"; - -const environmentId = EnvironmentId.make("env-mobile-test"); - -const connection = { - environmentId, - environmentLabel: "Mobile Test Desktop", - pairingUrl: "https://desktop.example/", - displayUrl: "https://desktop.example/", - httpBaseUrl: "https://desktop.example/", - wsBaseUrl: "wss://desktop.example/", - bearerToken: "remote-access-token", -} as const; - -describe("mobile remote environment registry effects", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); - mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); - mocks.environmentConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.reconnect.mockResolvedValue(undefined); - mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); - mocks.removeEnvironmentSession.mockReturnValue(null); - mocks.getEnvironmentSession.mockReturnValue(null); - mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); - mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); - mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), - ); - setManagedRelaySession(appAtomRegistry, null); - }); - - it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - - expect(mocks.saveConnection).toHaveBeenCalledWith(connection); - expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); - expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); - expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( - connection.environmentId, - expect.objectContaining({ - connection: mocks.environmentConnection, - }), - ); - expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: connection.environmentId }), - ); - expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - }), - ); - - it.effect("uses DPoP-bound admission for a managed DPoP connection", () => - Effect.gen(function* () { - const dpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - dpopAccessToken: "environment-dpop-token", - } as const; - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(dpopConnection); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://desktop.example/api/auth/websocket-ticket", - accessToken: "environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: dpopConnection.wsBaseUrl, - httpBaseUrl: dpopConnection.httpBaseUrl, - accessToken: "environment-dpop-token", - dpopProof: "dpop-proof", - }); - expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); - }), - ); - - it.effect("refreshes a persisted managed connection before reconnecting", () => - Effect.gen(function* () { - const savedDpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - relayManaged: true, - } as const; - const refreshedConnection = { - ...savedDpopConnection, - displayUrl: "https://rotated-desktop.example/", - httpBaseUrl: "https://rotated-desktop.example/", - wsBaseUrl: "wss://rotated-desktop.example/", - dpopAccessToken: "fresh-environment-dpop-token", - } as const; - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("fresh-clerk-token"), - }), - ); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ - clerkToken: "fresh-clerk-token", - connection: savedDpopConnection, - }); - const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; - expect(persistedConnection).toMatchObject({ - ...savedDpopConnection, - displayUrl: refreshedConnection.displayUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - wsBaseUrl: refreshedConnection.wsBaseUrl, - }); - expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://rotated-desktop.example/api/auth/websocket-ticket", - accessToken: "fresh-environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: refreshedConnection.wsBaseUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - accessToken: "fresh-environment-dpop-token", - dpopProof: "dpop-proof", - }); - }), - ); - - it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - const result = yield* Effect.exit(connectSavedEnvironment(connection)); - - expect(result._tag).toBe("Failure"); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - }), - ); - - it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - yield* connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }); - - expect(mocks.saveConnection).not.toHaveBeenCalled(); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("reconnects a stale saved environment session after app resume", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - vi.clearAllMocks(); - mocks.getEnvironmentSession.mockReturnValue({ - client: mocks.sessionClient, - connection: mocks.sessionConnection, - } as never); - - reconnectEnvironmentConnectionsAfterAppResume("test"); - - yield* Effect.promise(() => - vi.waitFor(() => { - expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); - }), - ); - expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ - environmentId: connection.environmentId, - }); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("disconnects and removes persisted managed endpoint state when requested", () => - Effect.gen(function* () { - mocks.removeEnvironmentSession.mockReturnValue({ - connection: mocks.sessionConnection, - } as never); - - yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); - - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith( - connection.environmentId, - ); - expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); - }), - ); -}); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index b7584858dc4..6fb41fc091f 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,90 +1,26 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; -import { Alert, AppState } from "react-native"; - -import { - type EnvironmentRuntimeState, - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - createKnownEnvironment, - createWsRpcClient, - EnvironmentConnectionState, - ManagedRelayDpopSigner, - WsTransport, - remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, - waitForManagedRelayClerkToken, -} from "@t3tools/client-runtime"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Order from "effect/Order"; +import type { ServerConfig } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { pipe } from "effect/Function"; -import { Atom } from "effect/unstable/reactivity"; -import { - type SavedRemoteConnection, - bootstrapRemoteConnection, - isRelayManagedConnection, - toStableSavedRemoteConnection, -} from "../lib/connection"; -import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; -import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; +import { Alert } from "react-native"; + +import { useEnvironmentServerConfig } from "../state/entities"; +import { useConnectionController } from "../features/connection/useConnectionController"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { - clearCachedShellSnapshot, - clearSavedConnection, - loadCachedShellSnapshot, - loadSavedConnections, - saveCachedShellSnapshot, - saveConnection, -} from "../lib/storage"; + projectEnvironmentPresentation, + type EnvironmentPresentation, +} from "../state/environments"; +import { useWorkspaceState } from "../state/workspace"; +import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; -import { mobileRuntime } from "../lib/runtime"; -import { - drainEnvironmentSessions, - getEnvironmentSession, - notifyEnvironmentConnectionListeners, - removeEnvironmentSession, - setEnvironmentSession, -} from "./environment-session-registry"; -import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import { - invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState, -} from "./use-source-control-discovery"; -import { - registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections, -} from "../features/agent-awareness/remoteRegistration"; -import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; -import { - clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot, - markShellSnapshotLive, - shellSnapshotManager, -} from "./use-shell-snapshot"; -import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; - -const terminalMetadataUnsubscribers = new Map void>(); -const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; -const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; - -interface RemoteEnvironmentLocalState { - readonly isLoadingSavedConnection: boolean; - readonly connectionPairingUrl: string; - readonly pendingConnectionError: string | null; - readonly savedConnectionsById: Record; -} - -const isLoadingSavedConnectionAtom = Atom.make(true).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:is-loading-saved-connection"), -); +import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; +import { environmentSession, usePreparedConnection } from "./session"; +import { environmentCatalog } from "../connection/catalog"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -96,680 +32,191 @@ const pendingConnectionErrorAtom = Atom.make(null).pipe( Atom.withLabel("mobile:pending-connection-error"), ); -const savedConnectionsByIdAtom = Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:saved-connections"), -); - -function getSavedConnectionsById(): Record { - return appAtomRegistry.get(savedConnectionsByIdAtom); -} - -function setIsLoadingSavedConnection(value: boolean): void { - appAtomRegistry.set(isLoadingSavedConnectionAtom, value); -} - -function setConnectionPairingUrl(pairingUrl: string): void { - appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); -} - -function clearConnectionPairingUrl(): void { - appAtomRegistry.set(connectionPairingUrlAtom, ""); -} - export function setPendingConnectionError(message: string | null): void { appAtomRegistry.set(pendingConnectionErrorAtom, message); } -function clearPendingConnectionError(): void { - appAtomRegistry.set(pendingConnectionErrorAtom, null); -} +function toSavedConnection( + environment: EnvironmentPresentation, + prepared: Option.Option, +): SavedRemoteConnection { + const displayUrl = environment.displayUrl ?? ""; + const active = Option.getOrNull(prepared); + const httpBaseUrl = active?.httpBaseUrl ?? displayUrl; + const socketUrl = active?.socketUrl ?? ""; + const wsBaseUrl = + socketUrl === "" + ? displayUrl.startsWith("https://") + ? displayUrl.replace(/^https:/, "wss:") + : displayUrl.replace(/^http:/, "ws:") + : new URL(socketUrl).origin; + const authorization = active?.httpAuthorization ?? null; -function replaceSavedConnections(connections: Record): void { - appAtomRegistry.set(savedConnectionsByIdAtom, connections); -} - -function upsertSavedConnection(connection: SavedRemoteConnection): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - appAtomRegistry.set(savedConnectionsByIdAtom, { - ...current, - [connection.environmentId]: connection, - }); + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + pairingUrl: displayUrl, + displayUrl, + httpBaseUrl, + wsBaseUrl, + bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, + ...(environment.relayManaged + ? { + authenticationMethod: "dpop" as const, + relayManaged: true as const, + ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}), + } + : { authenticationMethod: "bearer" as const }), + }; } -function removeSavedConnection(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(savedConnectionsByIdAtom, next); +const savedConnectionsByIdAtom = Atom.make((get) => { + const presentationById = get(environmentPresentations.presentationsAtom); + return Object.fromEntries( + [...presentationById.entries()].map(([environmentId, presentation]) => [ + environmentId, + toSavedConnection( + projectEnvironmentPresentation(environmentId, presentation), + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ), + ]), + ) as Record; +}).pipe(Atom.withLabel("mobile:saved-connections-by-id")); + +function toRuntimeState( + environment: EnvironmentPresentation, + serverConfig: ServerConfig | null, +): EnvironmentRuntimeState { + return { + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + serverConfig, + }; } -function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { - const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); - const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); - const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); +export function useSavedRemoteConnections() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); - return useMemo( - () => ({ - isLoadingSavedConnection, - connectionPairingUrl, - pendingConnectionError, - savedConnectionsById, - }), - [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], - ); -} - -function setEnvironmentConnectionStatus( - environmentId: EnvironmentId, - state: ConnectedEnvironmentSummary["connectionState"], - error?: string | null, -) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionState: state, - connectionError: error === undefined ? current.connectionError : error, - })); -} - -function fromPromise(tryPromise: () => Promise): Effect.Effect { - return Effect.tryPromise({ - try: tryPromise, - catch: (cause) => cause, - }); -} - -export function disconnectEnvironment( - environmentId: EnvironmentId, - options?: { - readonly preserveShellSnapshot?: boolean; - readonly removeSaved?: boolean; - readonly preserveConnectionAttempt?: boolean; - }, -): Effect.Effect { - return Effect.gen(function* () { - if (!options?.preserveConnectionAttempt) { - environmentConnectionAttempts.cancel(environmentId); - } - - const session = removeEnvironmentSession(environmentId); - notifyEnvironmentConnectionListeners(); - if (session) { - yield* fromPromise(() => session.connection.dispose()); - } - terminalMetadataUnsubscribers.get(environmentId)?.(); - terminalMetadataUnsubscribers.delete(environmentId); - unregisterAgentAwarenessConnection(environmentId); - if (!options?.preserveShellSnapshot) { - shellSnapshotManager.invalidate({ environmentId }); - } - invalidateSourceControlDiscoveryForEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - environmentRuntimeManager.invalidate({ environmentId }); - - if (options?.removeSaved) { - yield* Effect.all( - [ - fromPromise(() => clearSavedConnection(environmentId)), - fromPromise(() => clearCachedShellSnapshot(environmentId)), - ], - { concurrency: 2 }, - ); - clearCachedShellSnapshotMetadata(environmentId); - removeSavedConnection(environmentId); - } - }); -} - -export function connectSavedEnvironment( - connection: SavedRemoteConnection, - options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, -): Effect.Effect { - return Effect.gen(function* () { - const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); - const isCurrentAttempt = connectionAttempt.isCurrent; - let activeConnection = connection; - let initialDpopAccessToken = - options?.persist === false ? undefined : connection.dpopAccessToken; - - yield* disconnectEnvironment(connection.environmentId, { - preserveShellSnapshot: true, - preserveConnectionAttempt: true, - }); - if (!isCurrentAttempt()) { - return; - } - - if (options?.persist !== false) { - yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); - if (!isCurrentAttempt()) { - return; - } - } - - upsertSavedConnection(toStableSavedRemoteConnection(connection)); - setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - - const transport = new WsTransport( - () => - mobileRuntime.runPromise( - isRelayManagedConnection(connection) - ? Effect.gen(function* () { - let dpopAccessToken = initialDpopAccessToken; - initialDpopAccessToken = undefined; - if (!dpopAccessToken) { - const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); - const refreshedConnection = yield* refreshCloudEnvironmentConnection({ - clerkToken, - connection: activeConnection, - }); - const stableConnection = toStableSavedRemoteConnection(refreshedConnection); - activeConnection = refreshedConnection; - if (isCurrentAttempt()) { - yield* fromPromise(() => saveConnection(stableConnection)); - upsertSavedConnection(stableConnection); - } - dpopAccessToken = refreshedConnection.dpopAccessToken; - } - if (!dpopAccessToken) { - return yield* Effect.fail( - new Error("Managed environment connection did not return a DPoP access token."), - ); - } - const signer = yield* ManagedRelayDpopSigner; - const dpop = yield* signer.createProof({ - method: "POST", - url: remoteEndpointUrl( - activeConnection.httpBaseUrl, - "/api/auth/websocket-ticket", - ), - accessToken: dpopAccessToken, - }); - return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: activeConnection.wsBaseUrl, - httpBaseUrl: activeConnection.httpBaseUrl, - accessToken: dpopAccessToken, - dpopProof: dpop, - }); - }) - : resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, - bearerToken: connection.bearerToken ?? "", - }), - ), - { - onAttempt: () => { - if (!isCurrentAttempt()) { - return; - } - - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (previous) => { - const nextState = - previous.connectionState === "ready" || previous.connectionState === "reconnecting" - ? "reconnecting" - : "connecting"; - const keepSettledFailure = - previous.connectionState === "disconnected" && previous.connectionError !== null; - return { - ...previous, - connectionState: keepSettledFailure ? "disconnected" : nextState, - connectionError: keepSettledFailure ? previous.connectionError : null, - }; - }, - ); - }, - onError: (message) => { - if (isCurrentAttempt()) { - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - } - }, - onClose: (details) => { - if (!isCurrentAttempt()) { - return; - } - - const reason = - details.reason.trim().length > 0 - ? details.reason - : details.code === 1000 - ? null - : `Remote connection closed (${details.code}).`; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); - }, - }, - ); - - const client = createWsRpcClient(transport); - const environmentConnection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...createKnownEnvironment({ - id: connection.environmentId, - label: connection.environmentLabel, - source: "manual", - target: { - httpBaseUrl: connection.httpBaseUrl, - wsBaseUrl: connection.wsBaseUrl, - }, - }), - environmentId: connection.environmentId, - }, - client, - applyShellEvent: (event, environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.applyEvent({ environmentId }, event); - } - }, - syncShellSnapshot: (snapshot, environmentId) => { - if (!isCurrentAttempt()) { - return; - } - - shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); - markShellSnapshotLive(environmentId); - void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); - environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ - ...runtime, - connectionState: "ready", - connectionError: null, - })); - }, - onShellResubscribe: (environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.markPending({ environmentId }); - } - }, - onConfigSnapshot: (serverConfig) => { - if (isCurrentAttempt()) { - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (runtime) => ({ - ...runtime, - serverConfig, - }), - ); - } - }, - }); - - if (!isCurrentAttempt()) { - yield* fromPromise(() => environmentConnection.dispose()); - return; - } - - setEnvironmentSession(connection.environmentId, { - client, - connection: environmentConnection, - }); - - const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( - Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new Error("Environment did not respond before the connection timeout.")), - onSome: Effect.succeed, - }), - ), - Effect.tapError((error: unknown) => - isCurrentAttempt() - ? Effect.gen(function* () { - setEnvironmentConnectionStatus( - connection.environmentId, - "disconnected", - error instanceof Error ? error.message : "Failed to bootstrap remote connection.", - ); - const pendingSession = removeEnvironmentSession(connection.environmentId); - notifyEnvironmentConnectionListeners(); - if (pendingSession) { - yield* fromPromise(() => pendingSession.connection.dispose()); - } - }) - : Effect.void, - ), - ); - const bootstrapped = yield* options?.suppressBootstrapError - ? bootstrap.pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - : bootstrap.pipe(Effect.as(true)); - - if (!bootstrapped || !isCurrentAttempt()) { - return; - } - - terminalMetadataUnsubscribers.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client, - }), - ); - terminalDebugLog("registry:terminal-metadata-subscribed", { - environmentId: connection.environmentId, - }); - registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection)); - notifyEnvironmentConnectionListeners(); - }); + return { + isLoadingSavedConnection: !catalog.isReady, + savedConnectionsById, + }; } -export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { - const now = Date.now(); - if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { - return; +export function useSavedRemoteConnection( + environmentId: EnvironmentId | null, +): SavedRemoteConnection | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const prepared = usePreparedConnection(environmentId); + if (environmentId === null || presentation === null) { + return null; } - - for (const connection of Object.values(getSavedConnectionsById())) { - const session = getEnvironmentSession(connection.environmentId); - if (session?.client.isHeartbeatFresh()) { - continue; - } - - lastAppResumeReconnectAt = now; - terminalDebugLog("registry:app-resume-reconnect", { - environmentId: connection.environmentId, - reason, - hasSession: session !== null, - }); - - if (!session) { - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch((error: unknown) => { - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - continue; - } - - setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - void session.connection.reconnect().catch((error: unknown) => { - const message = - error instanceof Error ? error.message : "Failed to reconnect remote environment."; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: message, - }); - }); - } -} - -function subscribeAppResumeReconnects(): () => void { - let previousAppState = AppState.currentState; - const subscription = AppState.addEventListener("change", (nextAppState) => { - const wasInactive = previousAppState !== "active"; - previousAppState = nextAppState; - if (nextAppState === "active" && wasInactive) { - reconnectEnvironmentConnectionsAfterAppResume("appstate"); - } - }); - - return () => subscription.remove(); -} - -const environmentsSortOrder = Order.mapInput( - Order.Struct({ - environmentLabel: Order.String, - }), - (environment: ConnectedEnvironmentSummary) => ({ - environmentLabel: environment.environmentLabel, - }), -); - -function deriveConnectedEnvironments( - savedConnectionsById: Record, - environmentStateById: Record, -): ReadonlyArray { - return Arr.sort( - Object.values(savedConnectionsById).map((connection) => { - const runtime = environmentStateById[connection.environmentId]; - return { - environmentId: connection.environmentId, - environmentLabel: connection.environmentLabel, - displayUrl: connection.displayUrl, - isRelayManaged: isRelayManagedConnection(connection), - connectionState: runtime?.connectionState ?? "idle", - connectionError: runtime?.connectionError ?? null, - }; - }), - environmentsSortOrder, - ); -} - -export function useRemoteEnvironmentBootstrap() { - useEffect(() => { - let cancelled = false; - const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); - - void (async () => { - try { - const connections = await loadSavedConnections(); - if (cancelled) { - return; - } - - replaceSavedConnections( - Object.fromEntries( - connections.map((connection) => [connection.environmentId, connection]), - ), - ); - - setIsLoadingSavedConnection(false); - - await Promise.all( - connections.map(async (connection) => { - const cached = await loadCachedShellSnapshot(connection.environmentId); - if (!cancelled && cached) { - hydrateCachedShellSnapshot(cached); - } - }), - ); - - if (cancelled) { - return; - } - - await mobileRuntime.runPromise( - Effect.all( - connections.map((connection) => - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ), - { concurrency: "unbounded" }, - ), - ); - } catch { - if (!cancelled) { - setIsLoadingSavedConnection(false); - } - } - })(); - - return () => { - cancelled = true; - unsubscribeAppResumeReconnects(); - for (const session of drainEnvironmentSessions()) { - void session.connection.dispose(); - } - for (const unsubscribe of terminalMetadataUnsubscribers.values()) { - unsubscribe(); - } - terminalMetadataUnsubscribers.clear(); - environmentConnectionAttempts.clear(); - unregisterAllAgentAwarenessConnections(); - environmentRuntimeManager.invalidate(); - shellSnapshotManager.invalidate(); - resetSourceControlDiscoveryState(); - terminalSessionManager.invalidate(); - notifyEnvironmentConnectionListeners(); - }; - }, []); + return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared); } -export function useRemoteEnvironmentState() { - const state = useRemoteEnvironmentLocalState(); - const environmentStateById = useEnvironmentRuntimeStates( - Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), - ); - - return useMemo( - () => ({ - ...state, - environmentStateById, - }), - [environmentStateById, state], - ); +export function useRemoteEnvironmentRuntime( + environmentId: EnvironmentId | null, +): EnvironmentRuntimeState | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const serverConfig = useEnvironmentServerConfig(environmentId); + if (environmentId === null || presentation === null) { + return null; + } + return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig); } export function useRemoteConnectionStatus() { - const { environmentStateById, pendingConnectionError, savedConnectionsById } = - useRemoteEnvironmentState(); - - const connectedEnvironments = useMemo( - () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), - [environmentStateById, savedConnectionsById], - ); - - const connectionState = useMemo(() => { - if (connectedEnvironments.length === 0) { - return "idle"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if ( - connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") - ) { - return "reconnecting"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; - }, [connectedEnvironments]); - - const connectionError = useMemo( + const workspace = useWorkspaceState(); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); + const connectedEnvironments = useMemo>( () => - pipe( - Arr.appendAll( - [pendingConnectionError], - Arr.map(connectedEnvironments, (environment) => environment.connectionError), - ), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ), - [connectedEnvironments, pendingConnectionError], + workspace.environments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + displayUrl: environment.displayUrl, + isRelayManaged: environment.isRelayManaged, + connectionState: environment.connectionState, + connectionError: environment.connectionError, + connectionErrorTraceId: environment.connectionErrorTraceId, + })), + [workspace.environments], ); return { connectedEnvironments, - connectionState, - connectionError, + connectionState: workspace.state.connectionState, + connectionError: pendingConnectionError ?? workspace.state.connectionError, }; } export function useRemoteConnections() { - const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); + const controller = useConnectionController(); + const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); + const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => { + appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); + }, []); + const onConnectPress = useCallback( async (pairingUrl?: string) => { - try { - const nextPairingUrl = pairingUrl ?? connectionPairingUrl; - const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); - clearPendingConnectionError(); - await mobileRuntime.runPromise(connectSavedEnvironment(connection)); - clearConnectionPairingUrl(); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to pair with the environment.", - ); - throw error; + const nextPairingUrl = pairingUrl ?? connectionPairingUrl; + setPendingConnectionError(null); + const result = await controller.connectPairingUrl(nextPairingUrl); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const message = + error instanceof Error ? error.message : "Failed to pair with the environment."; + setPendingConnectionError(message); + } else { + appAtomRegistry.set(connectionPairingUrlAtom, ""); } + return result; }, - [connectionPairingUrl], + [connectionPairingUrl, controller], ); + const onReconnectEnvironment = useCallback( + (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), + [controller], + ); const onUpdateEnvironment = useCallback( - async ( + ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, - ) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection || isRelayManagedConnection(connection)) { + ) => controller.updateEnvironment(environmentId, updates), + [controller], + ); + + const onRemoveEnvironmentPress = useCallback( + (environmentId: EnvironmentId) => { + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!environment) { return; } - - const updated: SavedRemoteConnection = { - ...connection, - environmentLabel: updates.label.trim() || connection.environmentLabel, - displayUrl: updates.displayUrl.trim() || connection.displayUrl, - }; - - await saveConnection(updated); - upsertSavedConnection(updated); + Alert.alert( + "Remove environment?", + `Disconnect and forget ${environment.environmentLabel} on this device.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + void controller.removeEnvironment(environmentId); + }, + }, + ], + ); }, - [], + [connectedEnvironments, controller], ); - const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch(() => undefined); - }, []); - - const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - - Alert.alert( - "Remove environment?", - `Disconnect and forget ${connection.environmentLabel} on this device.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => { - void mobileRuntime - .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) - .catch(() => undefined); - }, - }, - ], - ); - }, []); - return { connectionPairingUrl, connectionState, @@ -777,7 +224,7 @@ export function useRemoteConnections() { pairingConnectionError: pendingConnectionError, connectedEnvironments, connectedEnvironmentCount: connectedEnvironments.length, - onChangeConnectionPairingUrl: setConnectionPairingUrl, + onChangeConnectionPairingUrl, onConnectPress, onReconnectEnvironment, onUpdateEnvironment, diff --git a/apps/mobile/src/state/use-selected-thread-commands.ts b/apps/mobile/src/state/use-selected-thread-commands.ts deleted file mode 100644 index a28d33c65d1..00000000000 --- a/apps/mobile/src/state/use-selected-thread-commands.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useCallback } from "react"; - -import { - CommandId, - type ModelSelection, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; - -import { uuidv4 } from "../lib/uuid"; -import { environmentRuntimeManager } from "./use-environment-runtime"; -import { getEnvironmentClient } from "./environment-session-registry"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; -import { useThreadSelection } from "./use-thread-selection"; - -export function useSelectedThreadCommands(input: { - readonly refreshSelectedThreadGitStatus: (options?: { - readonly quiet?: boolean; - readonly cwd?: string | null; - }) => Promise; -}) { - const { refreshSelectedThreadGitStatus } = input; - const { selectedThread } = useThreadSelection(); - const { savedConnectionsById } = useRemoteEnvironmentState(); - - const onRefresh = useCallback(async () => { - const targets = selectedThread - ? [selectedThread.environmentId] - : Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - - if (selectedThread) { - await refreshSelectedThreadGitStatus({ quiet: true }); - } - }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); - - const onUpdateThreadModelSelection = useCallback( - async (modelSelection: ModelSelection) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - modelSelection, - }); - }, - [selectedThread], - ); - - const onUpdateThreadRuntimeMode = useCallback( - async (runtimeMode: RuntimeMode) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - runtimeMode, - createdAt: new Date().toISOString(), - }); - }, - [selectedThread], - ); - - const onUpdateThreadInteractionMode = useCallback( - async (interactionMode: ProviderInteractionMode) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - interactionMode, - createdAt: new Date().toISOString(), - }); - }, - [selectedThread], - ); - - const onStopThread = useCallback(async () => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - if ( - selectedThread.session?.status !== "running" && - selectedThread.session?.status !== "starting" - ) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - ...(selectedThread.session?.activeTurnId - ? { turnId: selectedThread.session.activeTurnId } - : {}), - createdAt: new Date().toISOString(), - }); - }, [selectedThread]); - - const onRenameThread = useCallback( - async (title: string) => { - if (!selectedThread) { - return; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - const trimmed = title.trim(); - if (!trimmed || trimmed === selectedThread.title) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - title: trimmed, - }); - }, - [selectedThread], - ); - - return { - onRefresh, - onUpdateThreadModelSelection, - onUpdateThreadRuntimeMode, - onUpdateThreadInteractionMode, - onRenameThread, - onStopThread, - }; -} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 18860935f36..f320e9da710 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,32 +1,60 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - type VcsRef, type GitActionRequestInput, -} from "@t3tools/client-runtime"; -import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; + type VcsActionOperation, + type VcsRef, +} from "@t3tools/client-runtime/state/vcs"; +import type { GitRunStackedActionResult } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useBranches } from "../state/queries"; +import { threadEnvironment } from "../state/threads"; +import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; -import { getEnvironmentClient } from "./environment-session-registry"; +import { appAtomRegistry } from "./atom-registry"; import { setPendingConnectionError } from "./use-remote-environment-registry"; -import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; -import { vcsRefManager } from "./use-vcs-refs"; -import { vcsStatusManager } from "./use-vcs-status"; +import { useAtomCommand } from "./use-atom-command"; +import { showGitActionResult } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitActions() { + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); + const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); + const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); const { selectedThread, selectedThreadProject } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const runStackedAction = useAtomCommand( + vcsActionManager.runStackedAction({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }), + { reportFailure: false }, + ); const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; - + const branchTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadGitRootCwd, + query: null, + }), + [selectedThread?.environmentId, selectedThreadGitRootCwd], + ); + const branchState = useBranches(branchTarget); const updateThreadGitContext = useCallback( async ( thread: NonNullable, @@ -35,20 +63,16 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }, ) => { - const client = getEnvironmentClient(thread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: thread.id, - ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), - ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + return updateThreadMetadata({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), + ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + }, }); }, - [], + [updateThreadMetadata], ); const refreshSelectedThreadGitStatus = useCallback( @@ -62,266 +86,285 @@ export function useSelectedThreadGitActions() { return null; } - try { - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return null; - } - - const status = await vcsActionManager.refreshStatus( - { environmentId: selectedThread.environmentId, cwd }, - { ...client.vcs, runChangeRequest: client.git.runStackedAction }, - options, - ); - setPendingConnectionError(null); - return status; - } catch (error) { + const target = { environmentId: selectedThread.environmentId, cwd }; + const execute = () => + refreshStatus({ + environmentId: selectedThread.environmentId, + input: { cwd }, + }); + const result = options?.quiet + ? await execute() + : await vcsActionManager.track( + appAtomRegistry, + target, + { + operation: "refresh_status", + label: "Refreshing source control status", + }, + execute, + ); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Failed to refresh git status."; setPendingConnectionError(message); return null; } + setPendingConnectionError(null); + return result.value; }, - [selectedThread, selectedThreadCwd, selectedThreadProject], + [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], ); useEffect(() => { if (!selectedThread || !selectedThreadProject) { return; } - void refreshSelectedThreadGitStatus({ quiet: true }); }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); const runSelectedThreadGitMutation = useCallback( - async ( - operation: (input: { - readonly thread: EnvironmentScopedThreadShell; - readonly project: EnvironmentScopedProjectShell; + async ( + operation: VcsActionOperation, + label: string, + execute: (input: { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; readonly cwd: string; - }) => Promise, + }) => Promise>, + options?: { readonly managedExternally?: boolean }, ): Promise => { - if (!selectedThread || !selectedThreadProject) { + if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; } - const cwd = selectedThreadCwd; - if (!cwd) { - return null; - } - - try { - setPendingConnectionError(null); - return await operation({ + const target = { + environmentId: selectedThread.environmentId, + cwd: selectedThreadCwd, + }; + setPendingConnectionError(null); + const run = () => + execute({ thread: selectedThread, project: selectedThreadProject, - cwd, + cwd: selectedThreadCwd, }); - } catch (error) { + const result = + options?.managedExternally === true + ? await run() + : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); return null; } + return result.value; }, [selectedThread, selectedThreadCwd, selectedThreadProject], ); const refreshSelectedThreadBranches = useCallback(async (): Promise> => { - if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { - return []; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, - client.vcs, - { limit: 100 }, - ); - return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( - (branch) => !branch.isRemote, - ); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", - ); - return []; - } - }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); + branchState.refresh(); + return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( + (branch) => !branch.isRemote, + ); + }, [branchState]); const syncSelectedThreadBranchState = useCallback( async (input: { - readonly thread: EnvironmentScopedThreadShell; + readonly thread: EnvironmentThreadShell; readonly cwd: string; - readonly branchRootCwd?: string | null; readonly nextThreadState?: { readonly branch?: string | null; readonly worktreePath?: string | null; }; - }) => { + }): Promise> => { if (input.nextThreadState) { - await updateThreadGitContext(input.thread, input.nextThreadState); - } - - const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; - if (branchRootCwd) { - vcsRefManager.invalidate({ - environmentId: input.thread.environmentId, - cwd: branchRootCwd, - query: null, - }); - await refreshSelectedThreadBranches(); + const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); + if (AsyncResult.isFailure(updateResult)) { + return AsyncResult.failure(updateResult.cause); + } } - + branchState.refresh(); await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); + return AsyncResult.success(undefined); }, - [ - refreshSelectedThreadBranches, - refreshSelectedThreadGitStatus, - selectedThreadProject?.workspaceRoot, - updateThreadGitContext, - ], + [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], ); const onCheckoutSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.switchRef( - { environmentId: thread.environmentId, cwd }, - { refName: branch }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "switch_ref", + "Switching branch", + async ({ thread, cwd }) => { + const result = await switchRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + switchRef, + ], ); const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.createRef( - { environmentId: thread.environmentId, cwd }, - { - refName: branch, - switchRef: true, - }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_ref", + "Creating branch", + async ({ thread, cwd }) => { + const result = await createRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch, switchRef: true }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + createRef, + ], ); const onCreateSelectedThreadWorktree = useCallback( async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { - await runSelectedThreadGitMutation(async ({ thread, project }) => { - const result = await vcsActionManager.createWorktree( - { environmentId: thread.environmentId, cwd: project.workspaceRoot }, - { - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }, - ); - if (!result) { - return; - } - - await syncSelectedThreadBranchState({ - thread, - cwd: result.worktree.path, - branchRootCwd: project.workspaceRoot, - nextThreadState: { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_worktree", + "Creating worktree", + async ({ thread, project }) => { + const result = await createWorktree({ + environmentId: thread.environmentId, + input: { + cwd: project.workspaceRoot, + refName: nextWorktree.baseBranch, + newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + path: null, + }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd: result.value.worktree.path, + nextThreadState: { + branch: result.value.worktree.refName, + worktreePath: result.value.worktree.path, + }, + }); + return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; + }, + ); }, - [runSelectedThreadGitMutation, syncSelectedThreadBranchState], + [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], ); const onPullSelectedThreadBranch = useCallback(async () => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - if (result) { + await runSelectedThreadGitMutation( + "pull", + "Pulling latest changes", + async ({ thread, cwd }) => { + const result = await pull({ + environmentId: thread.environmentId, + input: { cwd }, + }); + if (AsyncResult.isFailure(result)) { + return result; + } + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); showGitActionResult({ type: "success", title: - result.status === "skipped_up_to_date" + result.value.status === "skipped_up_to_date" ? "Already up to date" - : `Pulled latest on ${result.refName}`, + : `Pulled latest on ${result.value.refName}`, }); - } - }); - }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); + return result; + }, + ); + }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.runChangeRequest( - { environmentId: thread.environmentId, cwd }, - { - actionId: uuidv4(), + const actionId = uuidv4(); + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const result = await runStackedAction({ + actionId, action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), - }, - { - gitStatus: vcsStatusManager.getSnapshot({ - environmentId: thread.environmentId, - cwd, - }).data, - }, - ); - if (!result) { - return null; - } - - showGitActionResult({ - type: "success", - title: result.toast.title, - description: result.toast.description, - prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, - }); + }); + if (AsyncResult.isFailure(result)) { + return result; + } - if (result.branch.status === "created" && result.branch.name) { - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result.branch.name, - worktreePath: selectedThreadWorktreePath, - }, + showGitActionResult({ + type: "success", + title: result.value.toast.title, + description: result.value.toast.description, + prUrl: + result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, }); - return result; - } - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - return result; - }); + if (result.value.branch.status === "created" && result.value.branch.name) { + const syncResult = await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.value.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + if (AsyncResult.isFailure(syncResult)) { + return AsyncResult.failure(syncResult.cause); + } + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + } + return result; + }, + { managedExternally: true }, + ); }, [ + runStackedAction, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, selectedThreadWorktreePath, diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index 6c855a3ebf7..a8c037db6f7 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -2,9 +2,10 @@ import { useMemo } from "react"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { useBranches } from "./queries"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; import { useVcsActionState } from "./use-vcs-action-state"; -import { useVcsRefs } from "./use-vcs-refs"; -import { useSourceControlDiscovery } from "./use-source-control-discovery"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; @@ -20,7 +21,14 @@ export function useSelectedThreadGitState() { [selectedThread?.environmentId, selectedThreadCwd], ); const gitActionState = useVcsActionState(selectedThreadGitTarget); - const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); + const sourceControlDiscovery = useEnvironmentQuery( + selectedThread === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedThread.environmentId, + input: {}, + }), + ); const selectedThreadBranchTarget = useMemo( () => ({ @@ -30,7 +38,7 @@ export function useSelectedThreadGitState() { }), [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], ); - const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); + const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); const selectedThreadBranches = useMemo( () => dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 232135b6a7e..c9e9db12530 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,9 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; +import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, @@ -12,11 +13,10 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; import { useSelectedThreadDetail } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; +import { useAtomCommand } from "./use-atom-command"; const userInputDraftsByRequestKeyAtom = Atom.make< Record> @@ -54,6 +54,14 @@ function setUserInputDraftCustomAnswer( } export function useSelectedThreadRequests() { + const respondToApproval = useAtomCommand( + threadEnvironment.respondToApproval, + "thread approval response", + ); + const respondToUserInput = useAtomCommand( + threadEnvironment.respondToUserInput, + "thread user input response", + ); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); @@ -112,26 +120,19 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingApprovalId(requestId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.approval.respond", - commandId: CommandId.make(uuidv4()), + const result = await respondToApproval({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, requestId, decision, - createdAt: new Date().toISOString(), - }); - } finally { - setRespondingApprovalId((current) => (current === requestId ? null : current)); - } + }, + }); + setRespondingApprovalId((current) => (current === requestId ? null : current)); + return result; }, - [selectedThreadShell], + [respondToApproval, selectedThreadShell], ); const onSubmitUserInput = useCallback(async () => { @@ -139,27 +140,25 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingUserInputId(activePendingUserInput.requestId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.user-input.respond", - commandId: CommandId.make(uuidv4()), + const result = await respondToUserInput({ + environmentId: selectedThreadShell.environmentId, + input: { threadId: selectedThreadShell.id, requestId: activePendingUserInput.requestId, answers: activePendingUserInputAnswers, - createdAt: new Date().toISOString(), - }); - } finally { - setRespondingUserInputId((current) => - current === activePendingUserInput.requestId ? null : current, - ); - } - }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); + }, + }); + setRespondingUserInputId((current) => + current === activePendingUserInput.requestId ? null : current, + ); + return result; + }, [ + activePendingUserInput, + activePendingUserInputAnswers, + respondToUserInput, + selectedThreadShell, + ]); return { activePendingApproval, diff --git a/apps/mobile/src/state/use-shell-snapshot.ts b/apps/mobile/src/state/use-shell-snapshot.ts deleted file mode 100644 index 56d69db7bfb..00000000000 --- a/apps/mobile/src/state/use-shell-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; -import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/unstable/reactivity"; -import { - EMPTY_SHELL_SNAPSHOT_ATOM, - EMPTY_SHELL_SNAPSHOT_STATE, - createShellSnapshotManager, - getShellSnapshotTargetKey, - shellSnapshotStateAtom, - type ShellSnapshotState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import type { CachedShellSnapshot } from "../lib/storage"; - -const cachedShellSnapshotMetadataAtom = Atom.make< - Readonly> ->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata")); - -export const shellSnapshotManager = createShellSnapshotManager({ - getRegistry: () => appAtomRegistry, -}); - -export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void { - shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot); - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, { - ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom), - [cached.environmentId]: { - snapshotReceivedAt: cached.snapshotReceivedAt, - }, - }); -} - -export function markShellSnapshotLive(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom); - if (current[environmentId] === undefined) { - return; - } - - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next); -} - -export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void { - markShellSnapshotLive(environmentId); -} - -export function useCachedShellSnapshotMetadata(): Readonly< - Record -> { - return useAtomValue(cachedShellSnapshotMetadataAtom); -} - -export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, - ); - return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; -} - -export function useShellSnapshotStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts deleted file mode 100644 index 8f206be2cee..00000000000 --- a/apps/mobile/src/state/use-source-control-discovery.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - type SourceControlDiscoveryClient, - type SourceControlDiscoveryState, - type SourceControlDiscoveryTarget, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function sourceControlDiscoveryTargetForEnvironment( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryTarget { - return { key: environmentId ?? null }; -} - -export function refreshSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, - client?: SourceControlDiscoveryClient | null, -): Promise { - return sourceControlDiscoveryManager.refresh( - sourceControlDiscoveryTargetForEnvironment(environmentId), - client ?? undefined, - ); -} - -export function invalidateSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, -): void { - sourceControlDiscoveryManager.invalidate( - sourceControlDiscoveryTargetForEnvironment(environmentId), - ); -} - -export function resetSourceControlDiscoveryState(): void { - sourceControlDiscoveryManager.reset(); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - resetSourceControlDiscoveryState(); -} - -export function useSourceControlDiscovery( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryState { - const target = useMemo( - () => sourceControlDiscoveryTargetForEnvironment(environmentId), - [environmentId], - ); - - useEffect(() => { - return sourceControlDiscoveryManager.watch(target); - }, [target]); - - const targetKey = getSourceControlDiscoveryTargetKey(target); - const state = useAtomValue( - targetKey !== null - ? sourceControlDiscoveryStateAtom(targetKey) - : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - ); - return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; -} diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 9ea13eef3e3..328557a2005 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 7dfdc4cd57e..60970b32a4d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,10 +1,9 @@ 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 { 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,7 +13,7 @@ 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, @@ -26,24 +25,12 @@ import { setComposerDraftText, 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 +63,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,10 +84,14 @@ 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; @@ -209,6 +100,7 @@ export function useThreadComposerState() { const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThreadSessionActivity = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } @@ -217,10 +109,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,71 +123,19 @@ export function useThreadComposerState() { selectedThreadSessionActivity, queuedSendStartedAt, ); - }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + }, [ + queuedSendStartedAt, + selectedThreadDetail, + selectedThreadSessionActivity, + selectedThreadShell, + ]); + const selectedThread = selectedThreadDetail ?? 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; } @@ -308,16 +149,22 @@ export function useThreadComposerState() { } 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); + try { + await enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId: MessageId.make(metadata.messageId), + commandId: CommandId.make(metadata.commandId), + text, + attachments, + createdAt: metadata.createdAt, + }); + clearComposerDraft(threadKey); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + } }, [composerDrafts, selectedThreadShell]); const onChangeDraftMessage = useCallback( @@ -385,7 +232,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], diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 900dbd648b5..388b4d9afcb 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,82 +1,26 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_THREAD_DETAIL_ATOM, - EMPTY_THREAD_DETAIL_STATE, - createThreadDetailManager, - getThreadDetailTargetKey, - threadDetailStateAtom, - type ThreadDetailState, - type ThreadDetailTarget, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; -import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useEnvironmentThread } from "./threads"; import { useThreadSelection } from "./use-thread-selection"; -function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { - const thread = state.data; - if (!thread || state.isDeleted) { - return false; - } - - if (thread.latestTurn?.sourceProposedPlan) { - return true; - } - - const sessionStatus = thread.session?.status; - if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { - return true; - } - - return ( - derivePendingApprovals(thread.activities).length > 0 || - derivePendingUserInputs(thread.activities).length > 0 - ); +export interface ThreadDetailTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; } -const threadDetailManager = createThreadDetailManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.orchestration : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - retention: { - idleTtlMs: 5 * 60 * 1_000, - maxRetainedEntries: 24, - shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), - }, -}); - -export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { - const { environmentId, threadId } = target; - const targetKey = getThreadDetailTargetKey(target); - - useEffect( - () => threadDetailManager.watch({ environmentId, threadId }), - [environmentId, threadId], - ); - - const state = useAtomValue( - targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, - ); - return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; +export function useThreadDetail(target: ThreadDetailTarget) { + return useEnvironmentThread(target.environmentId, target.threadId); } -export function useSelectedThreadDetail() { +export function useSelectedThreadDetailState() { const { selectedThread } = useThreadSelection(); - const state = useThreadDetail({ + return useThreadDetail({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); +} - return useMemo(() => state.data, [state.data]); +export function useSelectedThreadDetail() { + return Option.getOrNull(useSelectedThreadDetailState().data); } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts new file mode 100644 index 00000000000..840456e2d1c --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -0,0 +1,210 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { 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 { + resolveThreadOutboxDeliveryAction, + shouldRetryThreadOutboxDelivery, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, +} 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, + ); +} + +export function useThreadOutboxDrain(): void { + const startTurn = useAtomCommand(threadEnvironment.startTurn, { 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 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, + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(deliveryResult)) { + const error = Cause.squash(deliveryResult.cause); + const retry = + Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error); + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: deliveryResult.cause, + retry, + }); + if (retry) { + 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; + } + }, + [startTurn], + ); + + useEffect(() => { + if (dispatchingQueuedMessageId !== null) { + return; + } + + for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + continue; + } + if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + continue; + } + + const thread = findThread(threads, nextQueuedMessage); + if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { + continue; + } + + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, + ); + const deliveryAction = resolveThreadOutboxDeliveryAction({ + threadExists: thread !== undefined, + shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty", + environmentConnected: environment?.connectionState === "connected", + threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + }); + if (deliveryAction === "wait") { + continue; + } + + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + const delivery = + deliveryAction === "remove" + ? removeThreadOutboxMessage(nextQueuedMessage).then( + () => true, + (error) => { + console.warn("[thread-outbox] failed to remove message for a missing thread", { + environmentId: nextQueuedMessage.environmentId, + threadId: nextQueuedMessage.threadId, + messageId: nextQueuedMessage.messageId, + error, + }); + return false; + }, + ) + : thread !== undefined + ? sendQueuedMessage(nextQueuedMessage, thread) + : Promise.resolve(false); + void delivery + .then((sent) => { + if (sent) { + retryAttemptRef.current.delete(nextQueuedMessage.messageId); + retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + retryTimersRef.current.delete(nextQueuedMessage.messageId); + } + return; + } + + const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; + retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); + const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); + retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + } + const retryTimer = setTimeout(() => { + retryTimersRef.current.delete(nextQueuedMessage.messageId); + setRetryTick((current) => current + 1); + }, retryDelayMs); + retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); + }) + .finally(() => { + finishDispatchingQueuedMessage(nextQueuedMessage.messageId); + }); + return; + } + }, [ + connectedEnvironments, + dispatchingQueuedMessageId, + queuedMessagesByThreadKey, + retryTick, + sendQueuedMessage, + shellStatuses, + threads, + ]); +} diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts new file mode 100644 index 00000000000..fb090cd0886 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -0,0 +1,28 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentShell } from "./shell"; +import { threadOutboxManager } from "./thread-outbox"; + +const threadOutboxShellStatusesAtom = Atom.make( + (get): ReadonlyMap => { + const statuses = new Map(); + for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) { + const environmentId = queue[0]?.environmentId; + if (environmentId !== undefined && !statuses.has(environmentId)) { + statuses.set(environmentId, get(environmentShell.stateValueAtom(environmentId)).status); + } + } + return statuses; + }, +).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses")); + +export function useThreadOutboxMessages() { + return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); +} + +export function useThreadOutboxShellStatuses() { + return useAtomValue(threadOutboxShellStatusesAtom); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index c303faed617..06175b6d237 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,11 +1,12 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; -import { useRemoteCatalog } from "./use-remote-catalog"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; +import { useProject, useThreadShell } from "../state/entities"; +import { + useRemoteEnvironmentRuntime, + useSavedRemoteConnection, +} from "./use-remote-environment-registry"; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { return value ?? null; } -function deriveSelectedThread( - selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, - threads: ReadonlyArray, -): EnvironmentScopedThreadShell | null { - if (!selectedThreadRef) { - return null; - } - - return ( - threads.find( - (thread) => - thread.environmentId === selectedThreadRef.environmentId && - thread.id === selectedThreadRef.threadId, - ) ?? null - ); -} - -function deriveSelectedThreadProject( - selectedThread: EnvironmentScopedThreadShell | null, - projects: ReadonlyArray, -): EnvironmentScopedProjectShell | null { - if (!selectedThread) { - return null; - } - - return ( - projects.find( - (project) => - project.environmentId === selectedThread.environmentId && - project.id === selectedThread.projectId, - ) ?? null - ); -} - export function useThreadSelection() { - const { projects, threads } = useRemoteCatalog(); - const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -68,22 +33,21 @@ export function useThreadSelection() { threadId: ThreadId.make(threadId), }; }, [params.environmentId, params.threadId]); - const selectedThread = useMemo( - () => deriveSelectedThread(selectedThreadRef, threads), - [selectedThreadRef, threads], + const selectedThread = useThreadShell(selectedThreadRef); + const selectedProjectRef = useMemo( + () => + selectedThread === null + ? null + : { + environmentId: selectedThread.environmentId, + projectId: selectedThread.projectId, + }, + [selectedThread], ); - - const selectedThreadProject = useMemo( - () => deriveSelectedThreadProject(selectedThread, projects), - [projects, selectedThread], - ); - - const selectedEnvironmentConnection = selectedThread - ? (savedConnectionsById[selectedThread.environmentId] ?? null) - : null; - const selectedEnvironmentRuntime = selectedThread - ? (environmentStateById[selectedThread.environmentId] ?? null) - : null; + const selectedThreadProject = useProject(selectedProjectRef); + const selectedEnvironmentId = selectedThread?.environmentId ?? null; + const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); + const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); return { selectedThreadRef, diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index 64e4da958ef..e169005a07f 100644 --- a/apps/mobile/src/state/use-vcs-action-state.ts +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -1,40 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; -import { - type VcsActionState, - type VcsActionTarget, - EMPTY_VCS_ACTION_ATOM, - EMPTY_VCS_ACTION_STATE, - createVcsActionManager, - getVcsActionTargetKey, - vcsActionStateAtom, -} from "@t3tools/client-runtime"; +import { type VcsActionState, type VcsActionTarget } from "@t3tools/client-runtime/state/vcs"; +import { Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; - -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; - }, - getActionId: uuidv4, -}); +import { vcsActionManager } from "./vcs"; export function useVcsActionState(target: VcsActionTarget): VcsActionState { - const targetKey = getVcsActionTargetKey(target); - const state = useAtomValue( - targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, - ); - return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; + return useAtomValue(vcsActionManager.stateAtom(target)); } -// --------------------------------------------------------------------------- -// Git action result notification -// --------------------------------------------------------------------------- - export interface GitActionResultNotification { readonly type: "success" | "error"; readonly title: string; @@ -44,26 +19,28 @@ export interface GitActionResultNotification { const RESULT_DISMISS_MS = 5_000; -type ResultListener = (result: GitActionResultNotification | null) => void; -const resultListeners = new Set(); -let currentResult: GitActionResultNotification | null = null; +const gitActionResultAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:git-action-result"), +); let dismissTimer: ReturnType | null = null; function broadcast(result: GitActionResultNotification | null): void { - currentResult = result; - for (const listener of resultListeners) { - listener(result); - } + appAtomRegistry.set(gitActionResultAtom, result); } export function showGitActionResult(result: GitActionResultNotification): void { if (dismissTimer) clearTimeout(dismissTimer); broadcast(result); - dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); + dismissTimer = setTimeout(() => { + dismissTimer = null; + broadcast(null); + }, RESULT_DISMISS_MS); } export function dismissGitActionResult(): void { if (dismissTimer) clearTimeout(dismissTimer); + dismissTimer = null; broadcast(null); } @@ -71,23 +48,10 @@ export function useGitActionResultNotification(): { readonly result: GitActionResultNotification | null; readonly dismiss: () => void; } { - const [result, setResult] = useState(currentResult); - - useEffect(() => { - resultListeners.add(setResult); - setResult(currentResult); - return () => { - resultListeners.delete(setResult); - }; - }, []); - + const result = useAtomValue(gitActionResultAtom); return { result, dismiss: dismissGitActionResult }; } -// --------------------------------------------------------------------------- -// Unified git action progress (combines running state + result notification) -// --------------------------------------------------------------------------- - export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; export interface GitActionProgress { diff --git a/apps/mobile/src/state/use-vcs-refs.ts b/apps/mobile/src/state/use-vcs-refs.ts deleted file mode 100644 index 3af3a6e945e..00000000000 --- a/apps/mobile/src/state/use-vcs-refs.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts deleted file mode 100644 index e7d7049d332..00000000000 --- a/apps/mobile/src/state/use-vcs-status.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -/** - * Singleton VCS status manager for the mobile app. - * - * Uses ref-counted `onStatus` subscriptions (one per unique cwd) - * rather than one-shot `refreshStatus` RPCs. Multiple threads - * sharing the same cwd (i.e. same project, no worktree) share - * a single WS subscription. - * - * `subscribeClientChanges` ensures subscriptions are established - * even when the WS connection isn't ready at mount time, and - * re-established on reconnection. - */ -export const vcsStatusManager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -/** - * Subscribe to live VCS status for a target (environmentId + cwd). - * - * Mirrors the web's `useVcsStatus` hook. Automatically subscribes - * on mount, ref-counts shared cwds, and unsubscribes on unmount. - * Returns reactive `VcsStatusState` via Effect atoms. - */ -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - - useEffect( - () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts new file mode 100644 index 00000000000..dc8c251149f --- /dev/null +++ b/apps/mobile/src/state/vcs.ts @@ -0,0 +1,9 @@ +import { + createVcsActionManager, + createVcsEnvironmentAtoms, +} from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts new file mode 100644 index 00000000000..368cd0bc468 --- /dev/null +++ b/apps/mobile/src/state/workspace.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; + +import { environmentShellSummaryAtom } from "./shell"; +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import { useEnvironments } from "./environments"; + +export function useWorkspaceState() { + const { isReady, networkStatus, environments } = useEnvironments(); + const shellSummary = useAtomValue(environmentShellSummaryAtom); + const projectedEnvironments = useMemo( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const state = useMemo( + () => + projectWorkspaceState({ + isReady, + networkStatus, + environments: projectedEnvironments, + shellSummary, + }), + [isReady, networkStatus, projectedEnvironments, shellSummary], + ); + + return { + environments: projectedEnvironments, + state, + }; +} diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts new file mode 100644 index 00000000000..e51273d57de --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -0,0 +1,123 @@ +import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { + BearerConnectionProfile, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import type { EnvironmentPresentation } from "./environments"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +function environment( + phase: EnvironmentPresentation["connection"]["phase"], +): EnvironmentPresentation { + const connectionId = `bearer:${ENVIRONMENT_ID}`; + return { + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + displayUrl: "https://environment.example.test", + relayManaged: false, + entry: { + target: new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + connectionId, + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), + }, + connection: { + phase, + error: phase === "error" ? "Connection failed." : null, + traceId: phase === "error" ? "trace-1" : null, + }, + serverConfig: null, + }; +} + +const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = { + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}; + +const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { + ...EMPTY_SHELL_SUMMARY, + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + latestSnapshotUpdatedAt: "2026-06-07T00:00:00.000Z", +}; + +describe("mobile workspace projection", () => { + it("preserves explicit offline state without presenting it as a connection error", () => { + const projected = projectWorkspaceEnvironment(environment("offline")); + + expect(projected.connectionState).toBe("offline"); + expect(projected.connectionError).toBeNull(); + }); + + it("reports offline before stale connected presentations", () => { + const environments = [projectWorkspaceEnvironment(environment("connected"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "offline", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectionState).toBe("offline"); + expect(state.networkStatus).toBe("offline"); + expect(state.hasReadyEnvironment).toBe(false); + }); + + it("projects reconnecting environments dynamically from active phases", () => { + const environments = [ + projectWorkspaceEnvironment(environment("reconnecting")), + projectWorkspaceEnvironment({ + ...environment("connected"), + environmentId: EnvironmentId.make("environment-2"), + }), + ]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectingEnvironments).toHaveLength(1); + expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting"); + expect(state.hasConnectingEnvironment).toBe(true); + expect(state.hasReadyEnvironment).toBe(true); + }); + + it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { + const environments = [projectWorkspaceEnvironment(environment("reconnecting"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: CACHED_SHELL_SUMMARY, + }); + + expect(state.hasLoadedShellSnapshot).toBe(true); + expect(state.hasPendingShellSnapshot).toBe(true); + expect(state.hasReadyEnvironment).toBe(false); + expect(state.connectionState).toBe("reconnecting"); + }); +}); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts new file mode 100644 index 00000000000..44c43d6c880 --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.ts @@ -0,0 +1,107 @@ +import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { type NetworkStatus } from "@t3tools/client-runtime/connection"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +import type { EnvironmentPresentation } from "./environments"; + +export interface WorkspaceEnvironment { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly isRelayManaged: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; +} + +export interface WorkspaceState { + readonly isLoadingConnections: boolean; + readonly hasConnections: boolean; + readonly hasLoadedShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; + readonly hasReadyEnvironment: boolean; + readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: ReadonlyArray; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly shellSnapshotError: string | null; + readonly latestCachedSnapshotReceivedAt: string | null; + readonly networkStatus: NetworkStatus; +} + +export function projectWorkspaceEnvironment( + environment: EnvironmentPresentation, +): WorkspaceEnvironment { + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + displayUrl: environment.displayUrl ?? "", + isRelayManaged: environment.relayManaged, + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + }; +} + +function overallConnectionState( + environments: ReadonlyArray, + networkStatus: NetworkStatus, +): EnvironmentConnectionPhase { + if (environments.length === 0) { + return "available"; + } + if (networkStatus === "offline") { + return "offline"; + } + if (environments.some((environment) => environment.connectionState === "connected")) { + return "connected"; + } + if (environments.some((environment) => environment.connectionState === "reconnecting")) { + return "reconnecting"; + } + if (environments.some((environment) => environment.connectionState === "connecting")) { + return "connecting"; + } + if (environments.some((environment) => environment.connectionState === "error")) { + return "error"; + } + if (environments.some((environment) => environment.connectionState === "offline")) { + return "offline"; + } + return "available"; +} + +export function projectWorkspaceState(input: { + readonly isReady: boolean; + readonly networkStatus: NetworkStatus; + readonly environments: ReadonlyArray; + readonly shellSummary: EnvironmentShellSummary; +}): WorkspaceState { + const connectingEnvironments = input.environments.filter( + (environment) => + environment.connectionState === "connecting" || + environment.connectionState === "reconnecting", + ); + + return { + isLoadingConnections: !input.isReady, + hasConnections: input.environments.length > 0, + hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, + hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, + hasReadyEnvironment: + input.networkStatus !== "offline" && + input.environments.some((environment) => environment.connectionState === "connected"), + hasConnectingEnvironment: connectingEnvironments.length > 0, + connectingEnvironments, + connectionState: overallConnectionState(input.environments, input.networkStatus), + connectionError: + input.environments.find((environment) => environment.connectionError !== null) + ?.connectionError ?? null, + shellSnapshotError: input.shellSummary.firstError, + latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, + networkStatus: input.networkStatus, + }; +} + +export type ServerConfigByEnvironmentId = ReadonlyMap; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index bc1229811a2..ebc4f984b86 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"; @@ -73,7 +72,7 @@ import { } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; @@ -83,7 +82,7 @@ import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); function runGit(cwd: string, args: ReadonlyArray) { - return execFileSync("git", args, { + return NodeChildProcess.execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -180,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: ( @@ -296,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,12 +348,12 @@ export const makeOrchestrationIntegrationHarness = ( ), Layer.provideMerge( WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( @@ -379,7 +378,7 @@ export const makeOrchestrationIntegrationHarness = ( 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), @@ -399,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 e79897c740e..ccfb9c46742 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import { ApprovalRequestId, @@ -409,7 +409,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); @@ -456,7 +456,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); @@ -752,7 +752,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); yield* startTurn({ @@ -811,7 +811,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); yield* startTurn({ @@ -869,7 +869,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), true, ); - assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); + assert.equal( + NodeFS.readFileSync(NodePath.join(harness.workspaceDir, "README.md"), "utf8"), + "v2\n", + ); assert.equal( gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, @@ -1332,7 +1335,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); }), }); @@ -1390,7 +1393,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ], mutateWorkspace: ({ cwd }) => Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); }), }); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 57e93c5acdd..e703af4b1f4 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -25,7 +25,7 @@ import { import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import { makeTestProviderAdapterHarness, @@ -63,7 +63,7 @@ const makeIntegrationFixture = Effect.gen(function* () { }); const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); const shared = Layer.mergeAll( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef0..0d89775844d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node // @effect-diagnostics nodeBuiltinImport:off -import { appendFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; import * as Effect from "effect/Effect"; @@ -42,7 +42,7 @@ function logExit(reason: string): void { if (!exitLogPath) { return; } - appendFileSync(exitLogPath, `${reason}\n`, "utf8"); + NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); } process.once("SIGTERM", () => { @@ -693,7 +693,7 @@ const program = Effect.gen(function* () { } const payload = event.payload; return Effect.sync(() => { - appendFileSync( + NodeFS.appendFileSync( requestLogPath, payload.endsWith("\n") ? payload : `${payload}\n`, "utf8", diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 31f2ef6f1f7..b36c2b2d496 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import process from "node:process"; -import readline from "node:readline"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeProcess from "node:process"; +import * as NodeReadline from "node:readline"; import * as NodeTimers from "node:timers"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; @@ -56,19 +56,19 @@ type PendingRequest = { reject: (error: Error) => void; }; -const targetCwd = process.argv[2] ?? process.cwd(); -const targetModel = process.argv[3] ?? "gpt-5.4"; -const promptText = process.argv[4] ?? "helo"; -const targetReasoning = process.env.CURSOR_REASONING ?? ""; -const targetContext = process.env.CURSOR_CONTEXT ?? ""; -const targetFast = process.env.CURSOR_FAST ?? ""; -const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent"; -const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); -const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); +const targetCwd = NodeProcess.argv[2] ?? NodeProcess.cwd(); +const targetModel = NodeProcess.argv[3] ?? "gpt-5.4"; +const promptText = NodeProcess.argv[4] ?? "helo"; +const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; +const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; +const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; +const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "agent"; +const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); +const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); function logSection(title: string, value: unknown) { - process.stdout.write(`\n=== ${title} ===\n`); - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); + NodeProcess.stdout.write(`\n=== ${title} ===\n`); + NodeProcess.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } function fail(message: string): never { @@ -124,18 +124,18 @@ function sleep(ms: number) { } class JsonRpcChild { - readonly child: ChildProcessWithoutNullStreams; + readonly child: NodeChildProcess.ChildProcessWithoutNullStreams; readonly pending = new Map(); nextId = 1; closed = false; constructor(bin: string, args: string[], cwd: string) { const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); - this.child = spawn(spawnCommand.command, spawnCommand.args, { + this.child = NodeChildProcess.spawn(spawnCommand.command, spawnCommand.args, { cwd, shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env: NodeProcess.env, }); this.child.on("exit", (code, signal) => { @@ -155,14 +155,14 @@ class JsonRpcChild { this.pending.clear(); }); - const stdout = readline.createInterface({ input: this.child.stdout }); + const stdout = NodeReadline.createInterface({ input: this.child.stdout }); stdout.on("line", (line) => { void this.handleStdoutLine(line); }); - const stderr = readline.createInterface({ input: this.child.stderr }); + const stderr = NodeReadline.createInterface({ input: this.child.stderr }); stderr.on("line", (line) => { - process.stdout.write(`[stderr] ${line}\n`); + NodeProcess.stdout.write(`[stderr] ${line}\n`); }); } @@ -175,7 +175,7 @@ class JsonRpcChild { headers: [], ...message, }); - process.stdout.write(`>>> ${payload}\n`); + NodeProcess.stdout.write(`>>> ${payload}\n`); this.child.stdin.write(`${payload}\n`); } @@ -240,13 +240,13 @@ class JsonRpcChild { return; } - process.stdout.write(`<<< ${line}\n`); + NodeProcess.stdout.write(`<<< ${line}\n`); let message: JsonRpcMessage; try { message = JSON.parse(line) as JsonRpcMessage; } catch (error) { - process.stdout.write(`[parse-error] ${(error as Error).message}\n`); + NodeProcess.stdout.write(`[parse-error] ${(error as Error).message}\n`); return; } @@ -435,7 +435,7 @@ async function main() { } void main().catch((error: unknown) => { - process.stderr.write( + NodeProcess.stderr.write( `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, ); process.exitCode = 1; diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 6abd8f48e61..0cbe9176582 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -7,18 +7,18 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; -import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; -const configLayer = ServerConfig.layerTest(process.cwd(), { +const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( configLayer, - WorkspacePathsLive, - ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + WorkspacePaths.layer, + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -35,6 +35,8 @@ describe("AssetAccess", () => { yield* fileSystem.writeFileString(htmlPath, ''); yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + const canonicalCssPath = yield* fileSystem.realPath(cssPath); const result = yield* issueAssetUrl({ resource: { @@ -50,11 +52,11 @@ describe("AssetAccess", () => { expect(yield* resolveAsset(token, "report.html")).toEqual({ kind: "file", - path: htmlPath, + path: canonicalHtmlPath, }); expect(yield* resolveAsset(token, "report.css")).toEqual({ kind: "file", - path: cssPath, + path: canonicalCssPath, }); expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); expect(yield* resolveAsset(token, ".env")).toBeNull(); @@ -87,9 +89,45 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues exact workspace URLs for image previews", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-image-workspace-", + }); + const assetsDirectory = path.join(root, "assets"); + const imagePath = path.join(assetsDirectory, "icon.png"); + const siblingPath = path.join(assetsDirectory, "other.png"); + yield* fileSystem.makeDirectory(assetsDirectory, { recursive: true }); + yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71])); + yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71])); + const canonicalImagePath = yield* fileSystem.realPath(imagePath); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: imagePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "icon.png")).toEqual({ + kind: "file", + path: canonicalImagePath, + }); + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + expect(yield* resolveAsset(token, "../icon.png")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; @@ -120,6 +158,7 @@ describe("AssetAccess", () => { }); const faviconPath = path.join(root, "favicon.svg"); yield* fileSystem.writeFileString(faviconPath, ""); + const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -131,7 +170,7 @@ describe("AssetAccess", () => { faviconSuffix.slice(0, faviconSeparatorIndex), faviconSuffix.slice(faviconSeparatorIndex + 1), ), - ).toEqual({ kind: "file", path: faviconPath }); + ).toEqual({ kind: "file", path: canonicalFaviconPath }); yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 659413f4748..873e9fc3d37 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,5 +1,11 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAccessError } 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"; @@ -13,33 +19,25 @@ import { signPayload, timingSafeEqualBase64Url, } from "../auth/utils.ts"; -import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; -import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; export const FALLBACK_PROJECT_FAVICON_SVG = ``; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; -const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); const PREVIEW_ASSET_EXTENSIONS = new Set([ - ...PREVIEWABLE_EXTENSIONS, - ".avif", + ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, + ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".css", - ".gif", - ".ico", - ".jpeg", - ".jpg", ".js", ".mjs", ".otf", - ".png", - ".svg", ".ttf", - ".webp", ".woff", ".woff2", ]); @@ -52,6 +50,13 @@ const AssetClaimsSchema = Schema.Union([ baseRelativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file-exact"), + workspaceRoot: Schema.String, + relativePath: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -98,7 +103,7 @@ const failAccess = (message: string, cause?: unknown) => const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const resolved = yield* workspacePaths .resolveRelativePathWithinRoot(input) .pipe(Effect.orElseSucceed(() => null)); @@ -125,7 +130,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -144,8 +149,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const resolved = yield* workspacePaths .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); - if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { - return yield* failAccess("Only HTML and PDF files can open in the browser."); + if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { + return yield* failAccess("Only browser documents and images can be previewed."); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ workspaceRoot, @@ -154,20 +159,29 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (!canonicalFile) { return yield* failAccess("Workspace asset was not found."); } - claims = { - version: 1, - kind: "workspace-file", - workspaceRoot: yield* fileSystem - .realPath(workspaceRoot) - .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt, - }; + const canonicalWorkspaceRoot = yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); + claims = isWorkspaceImagePreviewPath(resolved.relativePath) + ? { + version: 1, + kind: "workspace-file-exact", + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt, + } + : { + version: 1, + kind: "workspace-file", + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; fileName = path.basename(resolved.relativePath); break; } case "attachment": { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, @@ -188,7 +202,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const workspaceRoot = yield* workspacePaths .normalizeWorkspaceRoot(input.resource.cwd) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); - const faviconResolver = yield* ProjectFaviconResolver; + const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; if ( @@ -211,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); @@ -230,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orElseSucceed(() => null)); @@ -241,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; if (claims.kind === "attachment") { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: claims.attachmentId, @@ -268,6 +282,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "workspace-file-exact") { + if (decodedPath !== path.basename(claims.relativePath)) return null; + const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: claims.workspaceRoot, + relativePath: claims.relativePath, + }); + return exactWorkspaceFile + ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) + : null; + } const segments = decodedPath.split(/[\\/]/); if ( decodedPath.length === 0 || diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index dc7db435426..a5216f76b98 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import NodePath from "node:path"; +import * as NodePath from "node:path"; export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 7703902105a..e21d9cf62cf 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; @@ -45,11 +45,13 @@ describe("attachmentStore", () => { }); it("resolves attachment path by id using the extension that exists on disk", () => { - const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), + ); try { const attachmentId = "thread-1-attachment"; - const pngPath = path.join(attachmentsDir, `${attachmentId}.png`); - fs.writeFileSync(pngPath, Buffer.from("hello")); + const pngPath = NodePath.join(attachmentsDir, `${attachmentId}.png`); + NodeFS.writeFileSync(pngPath, Buffer.from("hello")); const resolved = resolveAttachmentPathById({ attachmentsDir, @@ -57,12 +59,14 @@ describe("attachmentStore", () => { }); expect(resolved).toBe(pngPath); } finally { - fs.rmSync(attachmentsDir, { recursive: true, force: true }); + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); it("returns null when no attachment file exists for the id", () => { - const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), + ); try { const resolved = resolveAttachmentPathById({ attachmentsDir, @@ -70,7 +74,7 @@ describe("attachmentStore", () => { }); expect(resolved).toBeNull(); } finally { - fs.rmSync(attachmentsDir, { recursive: true, force: true }); + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 1e8dd93f603..3d5b531db21 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -39,7 +39,7 @@ export function createAttachmentId(threadId: string): string | null { if (!threadSegment) { return null; } - return `${threadSegment}-${randomUUID()}`; + return `${threadSegment}-${NodeCrypto.randomUUID()}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -89,7 +89,7 @@ export function resolveAttachmentPathById(input: { attachmentsDir: input.attachmentsDir, relativePath: `${normalizedId}${extension}`, }); - if (maybePath && existsSync(maybePath)) { + if (maybePath && NodeFS.existsSync(maybePath)) { return maybePath; } } diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..335e0685197 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -4,27 +4,26 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = (overrides?: Partial) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); -const makeEnvironmentAuthLayer = (overrides?: Partial) => +const makeEnvironmentAuthLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), @@ -33,13 +32,15 @@ const makeEnvironmentAuthLayer = (overrides?: Partial) => const makeCookieRequest = ( sessionToken: string, -): Parameters[0] => +): Parameters[0] => ({ cookies: { t3_session: sessionToken, }, headers: {}, - }) as unknown as Parameters[0]; + }) as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; const requestMetadata = { deviceType: "desktop" as const, @@ -52,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInvalidError({ - message: "Unknown bootstrap credential.", - }), + new PairingGrantStore.UnknownBootstrapCredentialError({}), ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - if (error._tag === "ServerAuthInvalidCredentialError") { - expect(error.reason).toBe("invalid_credential"); - } }), ); it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - message: "Failed to consume bootstrap credential.", - cause: new Error("sqlite is unavailable"), - }), - ); + const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ + cause: new Error("sqlite is unavailable"), + }); + const error = EnvironmentAuth.toBootstrapExchangeError(cause); - expect(error._tag).toBe("ServerAuthInternalError"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); expect(error.message).toBe("Failed to validate bootstrap credential."); + if (error._tag === "ServerAuthBootstrapCredentialValidationError") { + expect(error.cause).toBe(cause); + } }), ); @@ -116,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ) .pipe(Effect.flip); - expect(error._tag).toBe("ServerAuthInvalidRequestError"); - if (error._tag === "ServerAuthInvalidRequestError") { - expect(error.reason).toBe("scope_not_granted"); - } + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d8c0079089f..dd53a83ca95 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -20,12 +20,12 @@ import { import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; @@ -67,123 +67,429 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const serverAuthInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthBootstrapCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate bootstrap credential."; + } +} + +export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate session credential."; + } +} + +export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedSessionIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated session."; + } +} + +export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedAccessTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated access token."; + } +} + +export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkCreationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to create pairing link."; + } +} + +export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinksListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list pairing links."; + } +} + +export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} + +export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthSessionTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session token."; + } +} + +export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( + "ServerAuthSessionsListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list sessions."; + } +} + +export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthOtherSessionsRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthWebSocketTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayStateRecordError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to record DPoP proof replay state."; + } +} + +export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayKeyCalculationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to calculate DPoP replay key."; + } +} + +export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountVerificationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not verify the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not read the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountMissingError", + {}, +) { + override get message(): string { + return "Cloud linked user is not installed for this environment."; + } +} + +export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudLinkJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud link JWT."; + } +} + +export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintPublicKeyMissingError", + {}, +) { + override get message(): string { + return "Cloud mint public key is not installed for this environment."; + } +} + +export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudRelayIssuerMissingError", + {}, +) { + override get message(): string { + return "Cloud relay issuer is not installed for this environment."; + } +} -export class ServerAuthInvalidCredentialError extends Data.TaggedError( +export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudHealthJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud health JWT."; + } +} + +export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud mint JWT."; + } +} + +export const ServerAuthInternalError = Schema.Union([ + ServerAuthBootstrapCredentialValidationError, + ServerAuthSessionCredentialValidationError, + ServerAuthAuthenticatedSessionIssueError, + ServerAuthAuthenticatedAccessTokenIssueError, + ServerAuthPairingLinkCreationError, + ServerAuthPairingLinksListError, + ServerAuthPairingLinkRevocationError, + ServerAuthSessionTokenIssueError, + ServerAuthSessionsListError, + ServerAuthSessionRevocationError, + ServerAuthOtherSessionsRevocationError, + ServerAuthWebSocketTokenIssueError, + ServerAuthDpopReplayStateRecordError, + ServerAuthDpopReplayKeyCalculationError, + ServerAuthLinkedCloudAccountVerificationError, + ServerAuthLinkedCloudAccountReadError, + ServerAuthLinkedCloudAccountMissingError, + ServerAuthCloudLinkJwtSigningError, + ServerAuthCloudMintPublicKeyMissingError, + ServerAuthCloudRelayIssuerMissingError, + ServerAuthCloudHealthJwtSigningError, + ServerAuthCloudMintJwtSigningError, +]); +export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; +export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); + +export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( + "ServerAuthMissingCredentialError", + {}, +) { + override get message(): string { + return "Server authentication credential is missing."; + } +} + +export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass()( "ServerAuthInvalidCredentialError", -)<{ - readonly reason: "missing_credential" | "invalid_credential"; - readonly cause?: unknown; -}> {} - -export class ServerAuthInvalidRequestError extends Data.TaggedError( - "ServerAuthInvalidRequestError", -)<{ - readonly reason: "invalid_scope" | "scope_not_granted"; -}> {} - -export class ServerAuthForbiddenOperationError extends Data.TaggedError( - "ServerAuthForbiddenOperationError", -)<{ - readonly reason: "current_session_revoke_not_allowed"; -}> {} + { + diagnostic: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Server authentication credential is invalid."; + } +} -export interface EnvironmentAuthShape { - readonly getDescriptor: () => Effect.Effect; - readonly getSessionState: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly createBrowserSession: ( - credential: string, - requestMetadata: AuthClientMetadata, - ) => Effect.Effect< - { - readonly response: AuthBrowserSessionResult; - readonly sessionToken: string; - }, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly exchangeBootstrapCredentialForAccessToken: ( - credential: string, - requestedScopes: ReadonlyArray | undefined, - requestMetadata: AuthClientMetadata, - input?: { - readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect< - AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError - >; - readonly createPairingLink: (input?: { - readonly ttl?: Duration.Duration; - readonly label?: string; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly issuePairingCredential: ( - input?: AuthCreatePairingCredentialInput, - ) => Effect.Effect; - readonly issueStartupPairingCredential: () => Effect.Effect< - AuthPairingCredentialResult, - ServerAuthInternalError - >; - readonly listPairingLinks: (input?: { - readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, ServerAuthInternalError>; - readonly revokePairingLink: (id: string) => Effect.Effect; - readonly issueSession: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly scopes?: ReadonlyArray; - readonly label?: string; - }) => Effect.Effect; - readonly listSessions: () => Effect.Effect< - ReadonlyArray, - ServerAuthInternalError - >; - readonly revokeSession: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherSessionsExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly listClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect, ServerAuthInternalError>; - readonly revokeClientSession: ( - currentSessionId: AuthSessionId, - targetSessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly issueWebSocketTicket: ( - session: Pick, - ) => Effect.Effect; - readonly issueStartupPairingUrl: ( - baseUrl: string, - ) => Effect.Effect; +export const ServerAuthCredentialError = Schema.Union([ + ServerAuthMissingCredentialError, + ServerAuthInvalidCredentialError, +]); +export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; +export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); +export const serverAuthCredentialReason = ( + error: ServerAuthCredentialError, +): "missing_credential" | "invalid_credential" => + error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; + +export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( + "ServerAuthInvalidScopeError", + {}, +) { + override get message(): string { + return "The requested authentication scope is invalid."; + } } -export class EnvironmentAuth extends Context.Service()( - "t3/auth/EnvironmentAuth", -) {} +export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass()( + "ServerAuthScopeNotGrantedError", + {}, +) { + override get message(): string { + return "The requested authentication scope was not granted."; + } +} + +export const ServerAuthInvalidRequestError = Schema.Union([ + ServerAuthInvalidScopeError, + ServerAuthScopeNotGrantedError, +]); +export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; +export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); +export const serverAuthInvalidRequestReason = ( + error: ServerAuthInvalidRequestError, +): "invalid_scope" | "scope_not_granted" => + error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; + +export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( + "ServerAuthForbiddenOperationError", + {}, +) { + override get message(): string { + return "The current authentication session cannot revoke itself."; + } +} + +export class EnvironmentAuth extends Context.Service< + EnvironmentAuth, + { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly createBrowserSession: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect< + { + readonly response: AuthBrowserSessionResult; + readonly sessionToken: string; + }, + ServerAuthInvalidCredentialError | ServerAuthInternalError + >; + readonly exchangeBootstrapCredentialForAccessToken: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + AuthAccessTokenResult, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; + readonly createPairingLink: (input?: { + readonly ttl?: Duration.Duration; + readonly label?: string; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly issuePairingCredential: ( + input?: AuthCreatePairingCredentialInput, + ) => Effect.Effect; + readonly issueStartupPairingCredential: () => Effect.Effect< + AuthPairingCredentialResult, + ServerAuthInternalError + >; + readonly listPairingLinks: (input?: { + readonly excludeSubjects?: ReadonlyArray; + }) => Effect.Effect, ServerAuthInternalError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly issueSession: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly scopes?: ReadonlyArray; + readonly label?: string; + }) => Effect.Effect; + readonly listSessions: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + readonly revokeSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherSessionsExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, ServerAuthInternalError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueWebSocketTicket: ( + session: Pick, + ) => Effect.Effect; + readonly issueStartupPairingUrl: ( + baseUrl: string, + ) => Effect.Effect; + } +>()("t3/auth/EnvironmentAuth") {} type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -const toInternalError = - (message: string) => - (cause: unknown): ServerAuthInternalError => - new ServerAuthInternalError({ message, cause }); - export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { - if (cause._tag === "BootstrapCredentialInternalError") { - return new ServerAuthInternalError({ - message: "Failed to validate bootstrap credential.", - cause, - }); + if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { + return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", cause, }); } @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTags({ - SessionCredentialInvalidError: (cause) => - Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), - SessionCredentialInternalError: (cause) => - Effect.fail( - new ServerAuthInternalError({ - message: "Failed to validate session credential.", - cause, - }), - ), - }), + Effect.mapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? new ServerAuthInvalidCredentialError({ cause }) + : new ServerAuthSessionCredentialValidationError({ cause }), + ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } -export const make = Effect.fn("makeEnvironmentAuth")(function* () { +export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ServerAuthInvalidCredentialError | ServerAuthInternalError > => sessions.verify(token).pipe( - Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => - Effect.logWarning("Rejected authenticated session credential.").pipe( - Effect.annotateLogs({ - reason: cause.message, - }), - ), + Effect.tapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ) + : Effect.void, ), Effect.map((session) => ({ sessionId: session.sessionId, @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { mapSessionVerificationErrors, ); - const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const authenticateRequest = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { - return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); + return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (!dpopToken || dpopToken !== credential) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP-bound access token requires DPoP authorization.", + diagnostic: "DPoP-bound access token requires DPoP authorization.", }), ); } @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (dpopToken) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP authorization requires a proof-bound access token.", + diagnostic: "DPoP authorization requires a proof-bound access token.", }), ); } @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ); }; - const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => + const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => authenticateRequest(request).pipe( Effect.map( (session) => @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchTag("ServerAuthInvalidCredentialError", () => + Effect.catchIf(isServerAuthCredentialError, () => Effect.succeed({ authenticated: false, auth: descriptor, @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, ) => @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated session.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), ), ), Effect.map( @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => bootstrapCredentials.consume(credential, input).pipe( Effect.mapError(toBootstrapExchangeError), @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthInvalidRequestError({ - reason: "scope_not_granted", - }); + return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }) .pipe( Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated access token.", - cause, - }), + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ), ); - const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( + const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", )( function* (input) { @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, - Effect.mapError(toInternalError("Failed to create pairing link.")), + Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); - const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => + const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), - Effect.mapError(toInternalError("Failed to list pairing links.")), + Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); - const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => - bootstrapCredentials - .revoke(id) - .pipe( - Effect.mapError(toInternalError("Failed to revoke pairing link.")), - Effect.withSpan("EnvironmentAuth.revokePairingLink"), - ); + const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => + bootstrapCredentials.revoke(id).pipe( + Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokePairingLink"), + ); - const issueSession: EnvironmentAuthShape["issueSession"] = (input) => + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("Failed to issue session token.")), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); - const listSessions: EnvironmentAuthShape["listSessions"] = () => + const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), - Effect.mapError(toInternalError("Failed to list sessions.")), + Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); - const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => - sessions - .revoke(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke session.")), - Effect.withSpan("EnvironmentAuth.revokeSession"), - ); + const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => + sessions.revoke(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeSession"), + ); - const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( + const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( sessionId, ) => - sessions - .revokeAllExcept(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke other sessions.")), - Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), - ); + sessions.revokeAllExcept(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), + ); - const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => + const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => issuePairingCredentialForSubject({ scopes: input?.scopes ?? AuthStandardClientScopes, subject: "one-time-token", ...(input?.label ? { label: input.label } : {}), }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); - const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () => - issuePairingCredentialForSubject({ - scopes: AuthAdministrativeScopes, - subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = + () => + issuePairingCredentialForSubject({ + scopes: AuthAdministrativeScopes, + subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); - const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => + const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => clientSessions.map( @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.listClientSessions"), ); - const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( + const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({ - reason: "current_session_revoke_not_allowed", - }); + return yield* new ServerAuthForbiddenOperationError({}); } return yield* revokeSession(targetSessionId); }); - const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( + const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( currentSessionId, ) => revokeOtherSessionsExcept(currentSessionId).pipe( Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); - const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { const url = new URL(baseUrl); @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), ); - const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => + const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue websocket token.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), ); - const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => + const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( + request, + ) => authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); - const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = + const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { return yield* authenticateRequest(request); }); - return { + return EnvironmentAuth.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), getSessionState, @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, - } satisfies EnvironmentAuthShape; + }); }); -export const layer = Layer.effect(EnvironmentAuth, make()).pipe( +export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..03009270e15 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -3,31 +3,34 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-auth-control-plane-test-", + }), + ), ); const makeEnvironmentAuthLayer = ( - overrides?: Partial>, + overrides?: Partial>, ) => EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index c9f5dc6230d..95269fb6c37 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -3,21 +3,22 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ServerConfigShape } from "../config.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; -const makeEnvironmentAuthPolicyLayer = (overrides?: Partial) => +const makeEnvironmentAuthPolicyLayer = ( + overrides?: Partial, +) => EnvironmentAuthPolicy.layer.pipe( Layer.provide( Layer.effect( - ServerConfig, + ServerConfig.ServerConfig, Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; return { ...config, ...overrides, - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 205c85b0234..7ffef0ff0a5 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; -export interface EnvironmentAuthPolicyShape { - readonly getDescriptor: () => Effect.Effect; -} - export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, - EnvironmentAuthPolicyShape + { + readonly getDescriptor: () => Effect.Effect; + } >()("t3/auth/EnvironmentAuthPolicy") {} -export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { - const config = yield* ServerConfig; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { }), }; - return { + return EnvironmentAuthPolicy.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), - } satisfies EnvironmentAuthPolicyShape; + }); }); -export const layer = Layer.effect(EnvironmentAuthPolicy, make()); +export const layer = Layer.effect(EnvironmentAuthPolicy, make); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..b3c9b30f643 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -5,29 +5,28 @@ 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 { 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), @@ -62,7 +61,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 +85,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 +132,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 +149,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 +183,7 @@ 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())), ); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index e97696fbadd..8a7a4d2e40f 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,110 @@ 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 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", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} -export class BootstrapCredentialInternalError extends Data.TaggedError( - "BootstrapCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( + "PairingCredentialIssueError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to issue pairing credential."; + } +} -export type BootstrapCredentialError = - | BootstrapCredentialInvalidError - | BootstrapCredentialInternalError; +export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to consume bootstrap credential."; + } +} + +export const BootstrapCredentialInternalError = Schema.Union([ + ActivePairingLinksLoadError, + PairingLinkRevokeError, + PairingCredentialIssueError, + BootstrapCredentialConsumeError, +]); +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 +152,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,21 +198,10 @@ 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* () { @@ -178,10 +253,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,10 +281,10 @@ 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({ @@ -224,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } return revoked; }, - Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), + Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); - const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( + const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", )( function* (input) { @@ -265,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); return issued; }, - Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), + Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), ); - 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 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -293,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -304,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + error: new BootstrapCredentialProofKeyMismatchError({}), }, next, ]; @@ -371,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { const matching = yield* pairingLinks.getByCredential({ credential }); 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), + isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), ), ); - return { + return PairingGrantStore.of({ issueOneTimeToken, listActive, get streamChanges() { @@ -413,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }, revoke, consume, - } satisfies PairingGrantStoreShape; + }); }); -export const layer = Layer.effect(PairingGrantStore, make()).pipe( - Layer.provideMerge(AuthPairingLinkRepositoryLive), +export const layer = Layer.effect(PairingGrantStore, make).pipe( + Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 93339f4d4db..d4411fb9f3b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,14 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = () => @@ -145,13 +146,13 @@ const makeConcurrentCreateSecretStoreLayer = () => ); it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { - it.effect("returns null when a secret file does not exist", () => + it.effect("returns Option.none when a secret file does not exist", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const secret = yield* secretStore.get("missing-secret"); - expect(secret).toBeNull(); + assert.isTrue(Option.isNone(secret)); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -162,7 +163,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); - expect(Array.from(second)).toEqual(Array.from(first)); + assert.deepEqual(Array.from(second), Array.from(first)); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -178,10 +179,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { { concurrency: "unbounded" }, ); const persisted = yield* secretStore.get("session-signing-key"); + const persistedBytes = Option.getOrThrow(persisted); - expect(persisted).not.toBeNull(); - expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); - expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); + assert.deepEqual(Array.from(first), Array.from(persistedBytes)); + assert.deepEqual(Array.from(second), Array.from(persistedBytes)); }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), ); @@ -217,10 +218,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); - expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( - true, + assert.isTrue( + chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets")), ); - expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); + assert.isAtLeast(chmodCalls.filter((call) => call.mode === 0o600).length, 2); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -230,10 +231,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to read secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); + assert.include(error.message, "Failed to read secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), ); @@ -245,10 +246,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to persist secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); + assert.include(error.message, "Failed to persist secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), ); @@ -258,10 +259,10 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError); - expect(error.message).toContain("Failed to remove secret session-signing-key."); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); + assert.include(error.message, "Failed to remove secret session-signing-key."); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), ); }); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 3b84ba58377..5e9890c1ea2 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -1,53 +1,166 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; -export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const secretStoreErrorContext = { + resource: Schema.String, + cause: Schema.Defect(), +}; + +export class SecretStoreSecureError extends Schema.TaggedErrorClass()( + "SecretStoreSecureError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to secure ${this.resource}.`; + } +} + +export class SecretStoreReadError extends Schema.TaggedErrorClass()( + "SecretStoreReadError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to read ${this.resource}.`; + } +} + +export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to create temporary path for ${this.resource}.`; + } +} + +export class SecretStorePersistError extends Schema.TaggedErrorClass()( + "SecretStorePersistError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to persist ${this.resource}.`; + } +} + +export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreRandomGenerationError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to generate random bytes for ${this.resource}.`; + } +} + +export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( + "SecretStoreConcurrentReadError", + { + resource: Schema.String, + }, +) { + override get message(): string { + return `Failed to read ${this.resource} after concurrent creation.`; + } +} + +export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( + "SecretStoreRemoveError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to remove ${this.resource}.`; + } +} + +export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( + "SecretStoreDecodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to decode ${this.resource}.`; + } +} + +export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( + "SecretStoreEncodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to encode ${this.resource}.`; + } +} + +export const SecretStoreError = Schema.Union([ + SecretStoreSecureError, + SecretStoreReadError, + SecretStoreTemporaryPathError, + SecretStorePersistError, + SecretStoreRandomGenerationError, + SecretStoreConcurrentReadError, + SecretStoreRemoveError, + SecretStoreDecodeError, + SecretStoreEncodeError, +]); +export type SecretStoreError = typeof SecretStoreError.Type; +export const isSecretStoreError = Schema.is(SecretStoreError); const isPlatformError = (value: unknown): value is PlatformError.PlatformError => Predicate.isTagged(value, "PlatformError"); export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => - isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; - -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly create: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; -} + "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; -export class ServerSecretStore extends Context.Service()( - "t3/auth/ServerSecretStore", -) {} +export class ServerSecretStore extends Context.Service< + ServerSecretStore, + { + readonly get: (name: string) => Effect.Effect, SecretStoreError>; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly create: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; + } +>()("t3/auth/ServerSecretStore") {} -export const make = Effect.fn("makeServerSecretStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + new SecretStoreSecureError({ + resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), ), @@ -55,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStoreShape["get"] = (name) => + const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(null) + ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, + new SecretStoreReadError({ + resource: `secret ${name}`, cause, }), ), @@ -71,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.get"), ); - const set: ServerSecretStoreShape["set"] = (name, value) => { + const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to create temporary path for secret ${name}.`, + new SecretStoreTemporaryPathError({ + resource: `secret ${name}`, cause, }), ), @@ -94,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), @@ -108,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ); }; - const create: ServerSecretStoreShape["create"] = (name, value) => { + const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); return Effect.scoped( Effect.gen(function* () { @@ -123,62 +236,64 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), ); }; - const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( - Effect.flatMap((existing) => { - if (existing) { - return Effect.succeed(existing); - } - - return crypto.randomBytes(bytes).pipe( - Effect.mapError( - (cause) => - new SecretStoreError({ - message: `Failed to generate random bytes for secret ${name}.`, - cause, - }), - ), - Effect.flatMap((generated) => - create(name, generated).pipe( - Effect.as(Uint8Array.from(generated)), - Effect.catchTag("SecretStoreError", (error) => - isSecretAlreadyExistsError(error) - ? get(name).pipe( - Effect.flatMap((created) => - created !== null - ? Effect.succeed(created) - : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, - }), - ), - ), - ) - : Effect.fail(error), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + crypto.randomBytes(bytes).pipe( + Effect.mapError( + (cause) => + new SecretStoreRandomGenerationError({ + resource: `secret ${name}`, + cause, + }), + ), + Effect.flatMap((generated) => + create(name, generated).pipe( + Effect.as(Uint8Array.from(generated)), + Effect.catchIf(isSecretStoreError, (error) => + isSecretAlreadyExistsError(error) + ? get(name).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new SecretStoreConcurrentReadError({ + resource: `secret ${name}`, + }), + ), + }), + ), + ) + : Effect.fail(error), + ), + ), ), ), - ), - ); - }), + }), + ), Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStoreShape["remove"] = (name) => + const remove: ServerSecretStore["Service"]["remove"] = (name) => fileSystem.remove(resolveSecretPath(name)).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreError({ - message: `Failed to remove secret ${name}.`, + new SecretStoreRemoveError({ + resource: `secret ${name}`, cause, }), ), @@ -186,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.remove"), ); - return { + return ServerSecretStore.of({ get, set, create, getOrCreateRandom, remove, - } satisfies ServerSecretStoreShape; + }); }); -export const layer = Layer.effect(ServerSecretStore, make()); +export const layer = Layer.effect(ServerSecretStore, make); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..0dd5d797d19 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,7 +40,7 @@ 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([]), @@ -52,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessionRepository, 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())), ); @@ -106,8 +105,8 @@ 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)); - 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); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 8de145ca338..18008a7d0a1 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -10,7 +10,6 @@ import { 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 +20,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 +62,311 @@ 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: ( - 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; +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 SessionStore extends Context.Service()( - "t3/auth/SessionStore", -) {} +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", + {}, +) { + override get message(): string { + return "Session token expired."; + } +} + +export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( + "UnknownSessionTokenError", + {}, +) { + override get message(): string { + return "Unknown session token."; + } +} + +export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( + "SessionTokenRevokedError", + {}, +) { + override get message(): string { + return "Session token revoked."; + } +} + +export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass()( + "InvalidSessionExpirationClaimError", + {}, +) { + override get message(): string { + return "Invalid `exp` claim"; + } +} + +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", + {}, +) { + override get message(): string { + return "Websocket token expired."; + } +} + +export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( + "UnknownWebSocketSessionError", + {}, +) { + override get message(): string { + return "Unknown websocket session."; + } +} + +export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( + "WebSocketSessionExpiredError", + {}, +) { + override get message(): string { + return "Websocket session expired."; + } +} + +export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass()( + "WebSocketSessionRevokedError", + {}, +) { + 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", + { + 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", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session credential."; + } +} + +export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( + "SessionCredentialVerificationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify session credential."; + } +} + +export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "WebSocketTokenIssueError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( + "WebSocketTokenVerificationError", + { + ...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", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "OtherSessionsRevocationError", + { + ...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 +428,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 +476,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 +510,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,7 +537,7 @@ 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 issuedAt = yield* DateTime.now; @@ -322,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -368,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); - 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.", - }); + return yield* new SessionTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown session token.", - }); + return yield* new UnknownSessionTokenError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Session token revoked.", - }); + return yield* new SessionTokenRevokedError({}); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid `exp` claim", - }); + return yield* new InvalidSessionExpirationClaimError({}); } return { @@ -435,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify session credential.", - cause, - }), + : new SessionCredentialVerificationError({ 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) { @@ -464,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.map(base64UrlEncode), Effect.mapError( (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -473,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { expiresAt, }; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), + Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), ); - 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.", - }); + return yield* new MalformedWebSocketTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid websocket token signature.", - }); + return yield* new InvalidWebSocketTokenSignatureError({}); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid websocket token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket token expired.", - }); + return yield* new WebSocketTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown websocket session.", - }); + return yield* new UnknownWebSocketSessionError({}); } if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session expired.", - }); + return yield* new WebSocketSessionExpiredError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session revoked.", - }); + return yield* new WebSocketSessionRevokedError({}); } return { @@ -539,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify websocket token.", - cause, - }), + : new WebSocketTokenVerificationError({ cause }), ), ); - 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,10 +762,10 @@ 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({ @@ -588,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), + Effect.mapError((cause) => new SessionRevocationError({ cause })), ); - const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", )( function* (sessionId) { @@ -619,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revokedSessionIds.length; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), + Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), ); - return { + return SessionStore.of({ cookieName, issue, verify, @@ -636,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { revokeAllExcept, markConnected, markDisconnected, - } satisfies SessionStoreShape; + }); }); -export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessionRepositoryLive), -); +export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 76898bc9463..fa75c407b0c 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { SecretStorePersistError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist DPoP proof.", + new SecretStorePersistError({ + resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { - const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); + const cause = storeFailure("AlreadyExists"); + const error = mapDpopReplayStoreError(cause); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + if (error._tag === "ServerAuthInvalidCredentialError") { + expect(error.cause).toBe(cause); + } }); it("reports replay-store availability failures as internal errors", () => { const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - expect(error._tag).toBe("ServerAuthInternalError"); - if (error._tag === "ServerAuthInternalError") { + expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); + if (error._tag === "ServerAuthDpopReplayStateRecordError") { expect(error.message).toBe("Failed to record DPoP proof replay state."); } }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 66cd07f9e2e..87dc0c263e2 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import type * 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 { @@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) 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, }); @@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { ...(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 +70,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 +88,9 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.fail(mapDpopReplayStoreError(error)), + ), ); return result.thumbprint; }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..71fb00b970a 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; @@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -167,16 +169,19 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { ...session, scopes: new Set(session.scopes), }), + session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); }), @@ -198,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ), @@ -228,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ), ), ) .handle( @@ -262,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ) : undefined; yield* appendCredentialResponseHeaders; @@ -289,12 +295,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + traceRelayRequest, + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => + failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ), ) .handle( @@ -306,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("websocket_ticket_issuance_failed", error), ), ), @@ -331,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_credential_issuance_failed", error), ), ), @@ -344,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_links_load_failed", error), ), ), @@ -358,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_link_revoke_failed", error), ), ), @@ -371,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_sessions_load_failed", error), ), ), @@ -388,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTags({ - ServerAuthForbiddenOperationError: (error) => - failEnvironmentOperationForbidden(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - }), + Effect.catchTag("ServerAuthForbiddenOperationError", () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + ), ), ) .handle( @@ -405,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_session_revoke_failed", error), ), ), diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 7260ac7c54d..39f04988ac5 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -4,7 +4,7 @@ import type { AuthClientPresentationMetadata, } from "@t3tools/contracts"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as Crypto from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as Encoding from "effect/Encoding"; import * as Result from "effect/Result"; @@ -32,7 +32,7 @@ export function base64UrlDecodeUtf8(input: string): string { } export function signPayload(payload: string, secret: Uint8Array): string { - return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); + return NodeCrypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); } export function timingSafeEqualBase64Url(left: string, right: string): boolean { @@ -41,7 +41,7 @@ export function timingSafeEqualBase64Url(left: string, right: string): boolean { if (leftBuffer.length !== rightBuffer.length) { return false; } - return Crypto.timingSafeEqual(leftBuffer, rightBuffer); + return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); } function normalizeNonEmptyString(value: string | null | undefined): string | undefined { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 27a1d55e90d..5c713ff2be7 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 a3bbcc66d34..05155f32ec4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,9 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NFS from "node:fs"; -import * as path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { + BootstrapEnvelopeDecodeError, + BootstrapFdStatError, + BootstrapInputStreamOpenError, + readBootstrapEnvelope, +} from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); +const openSyncInterceptor = vi.hoisted(() => ({ + failPath: null as string | null, + errorCode: "ENXIO", +})); +const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => { filePath === openSyncInterceptor.failPath && flags === "r" ) { - const error = new Error("no such device or address"); - Object.assign(error, { code: "ENXIO" }); + const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`); + Object.assign(error, { code: openSyncInterceptor.errorCode }); throw error; } return (actual.openSync as (...a: typeof args) => number)(...args); }, + fstatSync: (...args: Parameters) => { + if (args[0] === fstatSyncInterceptor.failFd) { + const error = new Error("permission denied"); + Object.assign(error, { code: "EACCES" }); + throw error; + } + return (actual.fstatSync as (...a: typeof args) => NodeFS.Stats)(...args); + }, }; }); @@ -53,8 +70,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { ); const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), ); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); @@ -78,7 +95,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // so the stream owns the fd lifecycle and closes it asynchronously on end. // Attempting to also close it synchronously in a finalizer races with the // stream's async close and produces an uncaught EBADF. - const fd = NFS.openSync(filePath, "r"); + const fd = NodeFS.openSync(filePath, "r"); openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { @@ -94,27 +111,107 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd path, platform, and cause when opening the input stream fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const fdPath = `/proc/self/fd/${fd}`; + + openSyncInterceptor.failPath = fdPath; + openSyncInterceptor.errorCode = "EIO"; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip); + + assert.instanceOf(error, BootstrapInputStreamOpenError); + assert.equal(error.fd, fd); + assert.equal(error.platform, "linux"); + assert.equal(error.fdPath, fdPath); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO"); + assert.equal( + error.message, + `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`, + ); + } finally { + openSyncInterceptor.failPath = null; + openSyncInterceptor.errorCode = "ENXIO"; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { - const fd = NFS.openSync("/dev/null", "r"); - NFS.closeSync(fd); + const fd = NodeFS.openSync("/dev/null", "r"); + NodeFS.closeSync(fd); const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertNone(payload); }), ); + it.effect("preserves fd and cause when stat fails for a non-availability reason", () => + Effect.gen(function* () { + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync("/dev/null", "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + + fstatSyncInterceptor.failFd = fd; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapFdStatError); + assert.equal(error.fd, fd); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES"); + assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`); + } finally { + fstatSyncInterceptor.failFd = null; + } + }), + ); + + it.effect("preserves fd and schema cause when decoding the envelope fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, '{"mode":42}\n'); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapEnvelopeDecodeError); + assert.equal(error.fd, fd); + assert.isDefined(error.cause); + assert.equal( + error.message, + `Failed to decode bootstrap envelope from file descriptor ${fd}.`, + ); + }), + ); + it.effect("returns none when the bootstrap read times out before any value arrives", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = path.join(tempDir, "bootstrap.pipe"); + const fifoPath = NodePath.join(tempDir, "bootstrap.pipe"); - yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + yield* Effect.sync(() => NodeChildProcess.execFileSync("mkfifo", [fifoPath])); const _writer = yield* Effect.acquireRelease( Effect.sync(() => - spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + NodeChildProcess.spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { stdio: ["ignore", "ignore", "ignore"], }), ), @@ -125,8 +222,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { ); const fd = yield* Effect.acquireRelease( - Effect.sync(() => NFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => NFS.closeSync(fd)), + Effect.sync(() => NodeFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), ); const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 83d1d337888..0f2a5a436a3 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,10 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NFS from "node:fs"; -import * as Net from "node:net"; -import * as readline from "node:readline"; -import type { Readable } from "node:stream"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodeReadline from "node:readline"; +import type * as NodeStream from "node:stream"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -13,10 +12,64 @@ import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -class BootstrapError extends Data.TaggedError("BootstrapError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BootstrapFdStatError extends Schema.TaggedErrorClass()( + "BootstrapFdStatError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat bootstrap file descriptor ${this.fd}.`; + } +} + +export class BootstrapInputStreamOpenError extends Schema.TaggedErrorClass()( + "BootstrapInputStreamOpenError", + { + fd: Schema.Number, + platform: Schema.String, + fdPath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const path = this.fdPath === undefined ? "" : ` via '${this.fdPath}'`; + return `Failed to open bootstrap input stream for file descriptor ${this.fd}${path} on '${this.platform}'.`; + } +} + +export class BootstrapEnvelopeReadError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeReadError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export class BootstrapEnvelopeDecodeError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeDecodeError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export const BootstrapError = Schema.Union([ + BootstrapFdStatError, + BootstrapInputStreamOpenError, + BootstrapEnvelopeReadError, + BootstrapEnvelopeDecodeError, +]); +export type BootstrapError = typeof BootstrapError.Type; export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( schema: Schema.Codec, @@ -32,8 +85,11 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { - const input = readline.createInterface({ + return yield* Effect.callback< + Option.Option, + BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError + >((resume) => { + const input = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity, }); @@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } resume( Effect.fail( - new BootstrapError({ - message: "Failed to read bootstrap envelope.", + new BootstrapEnvelopeReadError({ + fd, cause: error, }), ), @@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } else { resume( Effect.fail( - new BootstrapError({ - message: "Failed to decode bootstrap envelope.", + new BootstrapEnvelopeDecodeError({ + fd, cause: parsed.failure, }), ), @@ -96,34 +152,34 @@ const isUnavailableBootstrapFdError = Predicate.compose( const isFdReady = (fd: number) => Effect.try({ - try: () => NFS.fstatSync(fd), + try: () => NodeFS.fstatSync(fd), catch: (error) => - new BootstrapError({ - message: "Failed to stat bootstrap fd.", + new BootstrapFdStatError({ + fd, cause: error, }), }).pipe( Effect.as(true), - Effect.catchIf( - (error) => isUnavailableBootstrapFdError(error.cause), - () => Effect.succeed(false), - ), + Effect.catchTags({ + BootstrapFdStatError: (error) => + isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.try({ + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ try: () => { - const fdPath = resolveFdPath(fd, platform); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); } let streamFd: number | undefined; try { - streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { + streamFd = NodeFS.openSync(fdPath, "r"); + return NodeFS.createReadStream("", { fd: streamFd, encoding: "utf8", autoClose: true, @@ -131,7 +187,7 @@ const makeBootstrapInputStream = (fd: number) => } catch (error) { if (isBootstrapFdPathDuplicationError(error)) { if (streamFd !== undefined) { - NFS.closeSync(streamFd); + NodeFS.closeSync(streamFd); } return makeDirectBootstrapStream(fd); } @@ -139,22 +195,24 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), cause: error, }), }); }); -const makeDirectBootstrapStream = (fd: number): Readable => { +const makeDirectBootstrapStream = (fd: number): NodeStream.Readable => { try { - return NFS.createReadStream("", { + return NodeFS.createReadStream("", { fd, encoding: "utf8", autoClose: true, }); } catch { - const stream = new Net.Socket({ + const stream = new NodeNet.Socket({ fd, readable: true, writable: false, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts new file mode 100644 index 00000000000..8654fa0fec1 --- /dev/null +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -0,0 +1,418 @@ +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"; + +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.message).toContain("Thread 'thread-missing' not found."); + }), + ); +}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts similarity index 80% rename from apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts rename to apps/server/src/checkpointing/CheckpointDiffQuery.ts index b07c06ac936..d42c58dfff3 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -1,23 +1,55 @@ +/** + * 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 { +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { CheckpointInvariantError, CheckpointUnavailableError } 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 +69,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; @@ -145,7 +177,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"; @@ -239,10 +271,10 @@ const make = Effect.gen(function* () { 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 86% rename from apps/server/src/checkpointing/Layers/CheckpointStore.test.ts rename to apps/server/src/checkpointing/CheckpointStore.test.ts index 778956e5206..5a60012108b 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,13 @@ function buildLargeText(lineCount = 5_000): string { .concat("\n"); } -it.layer(TestLayer)("CheckpointStoreLive", (it) => { +it.layer(TestLayer)("CheckpointStore.layer", (it) => { 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 +107,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 +130,12 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const componentPath = path.join(tmp, "Component.tsx"); + const componentPath = NodePath.join(tmp, "Component.tsx"); yield* writeTextFile( componentPath, [ diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts new file mode 100644 index 00000000000..ed47d5f117f --- /dev/null +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -0,0 +1,171 @@ +/** + * 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.resolve({ cwd, requestedKind: "git" }).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + + 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/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts deleted file mode 100644 index 9f31532855a..00000000000 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { describe, expect, it } from "vite-plus/test"; - -import { - ProjectionSnapshotQuery, - type ProjectionThreadCheckpointContext, -} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; -import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; - -function makeThreadCheckpointContext(input: { - readonly projectId: ProjectId; - readonly threadId: ThreadId; - readonly workspaceRoot: string; - readonly worktreePath: string | null; - readonly checkpointTurnCount: number; - readonly checkpointRef: CheckpointRef; -}): ProjectionThreadCheckpointContext { - return { - threadId: input.threadId, - projectId: input.projectId, - workspaceRoot: input.workspaceRoot, - worktreePath: input.worktreePath, - checkpoints: [ - { - turnId: TurnId.make("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }; -} - -describe("CheckpointDiffQueryLive", () => { - it("uses the narrow full-thread context lookup for all-turns diffs", async () => { - const projectId = ProjectId.make("project-full-thread"); - const threadId = ThreadId.make("thread-full-thread"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); - let getThreadCheckpointContextCalls = 0; - let getFullThreadDiffContextCalls = 0; - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "full thread diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => - Effect.sync(() => { - getThreadCheckpointContextCalls += 1; - return Option.none(); - }), - getFullThreadDiffContext: () => - Effect.sync(() => { - getFullThreadDiffContextCalls += 1; - return Option.some({ - threadId, - projectId, - workspaceRoot: "/tmp/workspace", - worktreePath: "/tmp/worktree", - latestCheckpointTurnCount: 4, - toCheckpointRef, - }); - }), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getFullThreadDiff({ - threadId, - toTurnCount: 4, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(getThreadCheckpointContextCalls).toBe(0); - expect(getFullThreadDiffContextCalls).toBe(1); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/worktree", - fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 4, - diff: "full thread diff patch", - }); - }); - - it("computes diffs using canonical turn-0 checkpoint refs", async () => { - const projectId = ProjectId.make("project-1"); - const threadId = ThreadId.make("thread-1"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/workspace", - fromCheckpointRef: expectedFromRef, - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - diff: "diff patch", - }); - }); - - it("defaults to hide whitespace changes", async () => { - const projectId = ProjectId.make("project-default-whitespace"); - const threadId = ThreadId.make("thread-default-whitespace"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ ignoreWhitespace }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); - }); - - it("does not preflight checkpoint refs before diffing", async () => { - const projectId = ProjectId.make("project-no-preflight"); - const threadId = ThreadId.make("thread-no-preflight"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - let hasCheckpointRefCallCount = 0; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => - Effect.sync(() => { - hasCheckpointRefCallCount += 1; - return true; - }), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed("diff patch"), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(hasCheckpointRefCallCount).toBe(0); - }); - - it("fails when the thread is missing from the snapshot", async () => { - const threadId = ThreadId.make("thread-missing"); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed(""), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await expect( - Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ), - ).rejects.toThrow("Thread 'thread-missing' not found."); - }); -}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts deleted file mode 100644 index 53b8d163e4c..00000000000 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * CheckpointStoreLive - Filesystem checkpoint store adapter layer. - * - * Resolves the active VCS driver once per checkpoint operation and delegates - * checkpoint-specific behavior to the driver's optional checkpoint capability. - * - * @module CheckpointStoreLive - */ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { VcsUnsupportedOperationError } from "@t3tools/contracts"; -import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; -import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts"; - -const makeCheckpointStore = Effect.gen(function* () { - const vcsRegistry = yield* VcsDriverRegistry; - - const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( - operation: string, - cwd: string, - ) { - const handle = yield* vcsRegistry.resolve({ cwd }); - if (!handle.driver.checkpoints) { - return yield* new VcsUnsupportedOperationError({ - operation, - kind: handle.kind, - detail: `${handle.kind} driver does not implement checkpoint operations.`, - }); - } - return handle.driver.checkpoints satisfies VcsCheckpointOps; - }); - - const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), - ); - - const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( - "captureCheckpoint", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); - return yield* checkpoints.captureCheckpoint(input); - }); - - const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")( - function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); - return yield* checkpoints.hasCheckpointRef(input); - }, - ); - - const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( - "restoreCheckpoint", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); - return yield* checkpoints.restoreCheckpoint(input); - }); - - const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( - function* (input) { - const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); - return yield* checkpoints.diffCheckpoints(input); - }, - ); - - const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( - "deleteCheckpointRefs", - )(function* (input) { - const checkpoints = yield* resolveCheckpoints( - "CheckpointStore.deleteCheckpointRefs", - input.cwd, - ); - return yield* checkpoints.deleteCheckpointRefs(input); - }); - - return { - isGitRepository, - captureCheckpoint, - hasCheckpointRef, - restoreCheckpoint, - diffCheckpoints, - deleteCheckpointRefs, - } satisfies CheckpointStoreShape; -}); - -export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts deleted file mode 100644 index 4bb8b111827..00000000000 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * CheckpointDiffQuery - Query interface for computed checkpoint diffs. - * - * Provides read-only diff operations across checkpoint snapshots used by - * orchestration APIs. - * - * @module CheckpointDiffQuery - */ -import type { - OrchestrationGetFullThreadDiffInput, - OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - OrchestrationGetTurnDiffResult, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { CheckpointServiceError } from "../Errors.ts"; - -/** - * CheckpointDiffQueryShape - Service API for checkpoint diff queries. - */ -export interface CheckpointDiffQueryShape { - /** - * Read the patch diff for a single turn checkpoint transition. - * - * Verifies checkpoint availability in both projection state and filesystem. - */ - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Effect.Effect; - - /** - * Read the full patch diff across a thread range of checkpoints. - * - * Delegates to turn diff with `fromTurnCount = 0`. - */ - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Effect.Effect; -} - -/** - * CheckpointDiffQuery - Service tag for checkpoint diff queries. - */ -export class CheckpointDiffQuery extends Context.Service< - CheckpointDiffQuery, - CheckpointDiffQueryShape ->()("t3/checkpointing/Services/CheckpointDiffQuery") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts deleted file mode 100644 index a7c4c3dbef0..00000000000 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. - * - * Owns hidden Git-ref checkpoint capture/restore and diff computation for a - * workspace thread timeline. It does not store user-facing checkpoint metadata - * and does not coordinate provider conversation rollback. - * - * Uses Effect `Context.Service` for dependency injection and exposes typed - * domain errors for checkpoint storage operations. - * - * @module CheckpointStore - */ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { CheckpointStoreError } from "../Errors.ts"; -import { CheckpointRef } from "@t3tools/contracts"; - -export interface CaptureCheckpointInput { - readonly cwd: string; - readonly checkpointRef: CheckpointRef; -} - -export interface RestoreCheckpointInput { - readonly cwd: string; - readonly checkpointRef: CheckpointRef; - readonly fallbackToHead?: boolean; -} - -export interface DiffCheckpointsInput { - readonly cwd: string; - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly fallbackFromToHead?: boolean; - readonly ignoreWhitespace: boolean; -} - -export interface DeleteCheckpointRefsInput { - readonly cwd: string; - readonly checkpointRefs: ReadonlyArray; -} - -/** - * CheckpointStoreShape - Service API for checkpoint capture/restore and diff access. - */ -export interface CheckpointStoreShape { - /** - * Check whether cwd is inside a Git worktree. - */ - readonly isGitRepository: (cwd: string) => Effect.Effect; - - /** - * Capture a checkpoint commit and store it at the provided checkpoint ref. - * - * Uses an isolated temporary Git index and writes a hidden ref. - */ - readonly captureCheckpoint: ( - input: CaptureCheckpointInput, - ) => Effect.Effect; - - /** - * Check whether a checkpoint ref exists. - */ - readonly hasCheckpointRef: ( - input: Omit, - ) => Effect.Effect; - - /** - * Restore workspace/staging state to a checkpoint. - * - * Optionally falls back to current `HEAD` when the checkpoint ref is missing. - */ - readonly restoreCheckpoint: ( - input: RestoreCheckpointInput, - ) => Effect.Effect; - - /** - * Compute patch diff between two checkpoint refs. - * - * Can optionally treat missing "from" ref as `HEAD`. - */ - readonly diffCheckpoints: ( - input: DiffCheckpointsInput, - ) => Effect.Effect; - - /** - * Delete the provided checkpoint refs. - * - * Best-effort delete: missing refs are tolerated. - */ - readonly deleteCheckpointRefs: ( - input: DeleteCheckpointRefsInput, - ) => Effect.Effect; -} - -/** - * CheckpointStore - Service tag for checkpoint persistence and restore operations. - */ -export class CheckpointStore extends Context.Service()( - "t3/checkpointing/Services/CheckpointStore", -) {} diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 4f1fc48871d..1b349111811 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -18,7 +18,7 @@ import { formatPairingCredentialList, formatSessionList, } from "../cliAuthFormat.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -28,7 +28,7 @@ import { const runWithEnvironmentAuth = ( flags: CliAuthLocationFlags, - run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect, + run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, options?: { readonly quietLogs?: boolean; }, @@ -43,7 +43,7 @@ const runWithEnvironmentAuth = ( }).pipe( Effect.provide( Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), ), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7182854e18c..7a9cd72d526 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.ts b/apps/server/src/cli/connect.ts index 54f9fd40da9..314680b0d80 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -31,9 +31,8 @@ import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; -import { ServerConfig } from "../config.ts"; -import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -145,7 +144,7 @@ const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( function* ( - relayClient: RelayClient.RelayClientShape, + relayClient: RelayClient.RelayClient["Service"], confirmInstall: (version: string) => Effect.Effect, reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, ) { @@ -164,7 +163,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( @@ -183,7 +182,7 @@ type LiveCloudActionResult = | { readonly status: "failed"; readonly cause: unknown }; 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; @@ -219,7 +218,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; @@ -285,8 +284,8 @@ const runCloudCommand = ( | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment - | ServerConfig - | ServerEnvironment + | ServerConfig.ServerConfig + | ServerEnvironment.ServerEnvironment >, options?: { readonly quietLogs?: boolean; @@ -301,11 +300,11 @@ const runCloudCommand = ( CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, - ServerEnvironmentLive, + ServerEnvironment.layer, headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(Layer.succeed(ServerConfig, config)), + Layer.provideMerge(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ); return yield* run.pipe(Effect.provide(runtimeLayer)); @@ -385,9 +384,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.ts b/apps/server/src/cli/project.ts index 0d8e7eca15d..d52d5b214d8 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -26,19 +26,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 = { @@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe( ); const ProjectCliRuntimeLive = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceLayerLive), ), ); @@ -78,7 +77,7 @@ const ProjectCliRuntimeLive = Layer.mergeAll( 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( @@ -123,7 +122,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); }); @@ -211,12 +210,15 @@ const dispatchLiveOrchestrationCommand = ( }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest)); 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 }>(); @@ -251,7 +253,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 +284,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 +300,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)), ), ), @@ -341,7 +347,7 @@ const projectAddCommand = Command.make("add", { projectId, title, workspaceRoot, - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), createdAt: DateTime.formatIso(yield* DateTime.now), }); return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index 2798f5b6ede..3fbf4f12db2 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -1,7 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerConfig } from "../config.ts"; @@ -40,18 +41,18 @@ it.layer(NodeServices.layer)("CliState", (it) => { Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + assert.isFalse(yield* CliState.readCliDesiredCloudLink); yield* CliState.setCliDesiredCloudLink(true); - expect(yield* CliState.readCliDesiredCloudLink).toBe(true); + assert.isTrue(yield* CliState.readCliDesiredCloudLink); for (const name of persistedCloudLinkSecrets) { yield* secrets.set(name, new TextEncoder().encode(name)); } yield* CliState.clearPersistedCloudLink; - expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + assert.isFalse(yield* CliState.readCliDesiredCloudLink); for (const name of persistedCloudLinkSecrets) { - expect(yield* secrets.get(name)).toBe(null); + assert.isTrue(Option.isNone(yield* secrets.get(name))); } }).pipe(Effect.provide(makeTestLayer())), ); diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index f344a0b73cc..2e18fff4250 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -17,7 +18,7 @@ const TRUE_BYTES = new TextEncoder().encode("true"); export const readCliDesiredCloudLink = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - return (yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)) !== null; + return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)); }); export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 765ef058332..00709370b26 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -1,12 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. -import { createServer } from "node:http"; +import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -15,10 +14,12 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; @@ -45,35 +46,74 @@ const OAuthTokenResponse = Schema.Struct({ token_type: Schema.String, }); -export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( + "CloudCliCredentialRemovalError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not remove the stored T3 Connect CLI credential."; + } +} + +export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass()( + "CloudCliCredentialRefreshError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not refresh the T3 Connect CLI credential."; + } +} + +export class CloudCliCredentialReadError extends Schema.TaggedErrorClass()( + "CloudCliCredentialReadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read the stored T3 Connect CLI credential."; + } +} + +export class CloudCliAuthorizationError extends Schema.TaggedErrorClass()( + "CloudCliAuthorizationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not authorize the T3 Connect CLI."; + } +} -export interface CloudCliTokenManagerShape { - readonly get: Effect.Effect; - readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; - readonly hasCredential: Effect.Effect; - readonly clear: Effect.Effect; +export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass()( + "CloudCliAuthorizationTimeoutError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Timed out waiting for T3 Connect authorization."; + } } +export const CloudCliTokenManagerError = Schema.Union([ + CloudCliCredentialRemovalError, + CloudCliCredentialRefreshError, + CloudCliCredentialReadError, + CloudCliAuthorizationError, + CloudCliAuthorizationTimeoutError, +]); +export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; + export class CloudCliTokenManager extends Context.Service< CloudCliTokenManager, - CloudCliTokenManagerShape + { + readonly get: Effect.Effect; + readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; + readonly hasCredential: Effect.Effect; + readonly clear: Effect.Effect; + } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} const wrapError = - (message: string) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError( - (cause) => - new CloudCliTokenManagerError({ - message, - cause, - }), - ), - ); + (makeError: (cause: unknown) => WrappedError) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError(makeError)); function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); @@ -83,7 +123,7 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); const secrets = yield* ServerSecretStore.ServerSecretStore; @@ -96,12 +136,12 @@ const make = Effect.gen(function* () { const clear = secrets .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError("Could not remove the stored T3 Connect CLI credential.")); + .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); - if (!encoded) return Option.none(); - return Option.some(yield* decodePersistedToken(bytesToString(encoded))); + if (Option.isNone(encoded)) return Option.none(); + return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); }); const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( @@ -166,7 +206,7 @@ const make = Effect.gen(function* () { disableLogger: true, }).pipe( Layer.provide( - NodeHttpServer.layer(createServer, { + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 34338, disablePreemptiveShutdown: true, @@ -185,10 +225,10 @@ const make = Effect.gen(function* () { yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", () => + Effect.catchTag("TimeoutError", (cause) => Effect.fail( - new CloudCliTokenManagerError({ - message: "Timed out waiting for T3 Connect authorization.", + new CloudCliAuthorizationTimeoutError({ + cause, }), ), ), @@ -213,12 +253,12 @@ const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError("Could not refresh the T3 Connect CLI credential.")), + getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), ); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError("Could not read the stored T3 Connect CLI credential."), + wrapError((cause) => new CloudCliCredentialReadError({ cause })), ), ); const get = semaphore.withPermits(1)( @@ -227,7 +267,7 @@ const make = Effect.gen(function* () { return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError("Could not authorize the T3 Connect CLI.")), + }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), ); return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 9ce33deaaef..e0d5924fcc2 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -4,13 +4,15 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const relayClientAvailableLayer = Layer.succeed( RelayClient.RelayClient, @@ -26,12 +28,33 @@ const relayClientAvailableLayer = Layer.succeed( }), ); -const runtimeDependencies = (spawner: ReturnType) => +const runtimeDependencies = ( + spawner: ReturnType, + relayClientLayer = relayClientAvailableLayer, +) => Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - relayClientAvailableLayer, + relayClientLayer, + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.succeed(Option.none()), + }), ); +const buildCloudManagedEndpointRuntime = ( + spawner: ReturnType, + relayClientLayer = relayClientAvailableLayer, +) => + Effect.gen(function* () { + const context = yield* Layer.build( + ManagedEndpointRuntime.layer.pipe( + Layer.provide(runtimeDependencies(spawner, relayClientLayer)), + ), + ); + return yield* Effect.service(ManagedEndpointRuntime.CloudManagedEndpointRuntime).pipe( + Effect.provide(context), + ); + }); + function makeHandle(input: { readonly pid: number; readonly onKill: () => void; @@ -57,6 +80,24 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + it("classifies Cloudflare connection and warning output", () => { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", + ), + ).toBe("connected"); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z ERR Failed to serve tunnel connection", + ), + ).toBe("warning"); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Starting metrics server", + ), + ).toBe("debug"); + }); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; @@ -80,9 +121,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -113,8 +152,8 @@ describe("CloudManagedEndpointRuntime", () => { "token-1", "token-2", ]); - expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); - expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); + expect(spawned.map((command) => command.options.stdout)).toEqual(["pipe", "pipe"]); + expect(spawned.map((command) => command.options.stderr)).toEqual(["pipe", "pipe"]); expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); expect(killed).toEqual([100, 101]); @@ -137,9 +176,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const started = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -176,9 +213,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const config = { providerKind: "cloudflare_tunnel" as const, connectorToken: "token", @@ -223,9 +258,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const started = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -265,9 +298,7 @@ describe("CloudManagedEndpointRuntime", () => { return handle; }), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const first = yield* runtime .applyConfig({ @@ -305,9 +336,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ), ); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(runtimeDependencies(spawner)), - ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); const status = yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", @@ -327,22 +356,18 @@ describe("CloudManagedEndpointRuntime", () => { Effect.gen(function* () { const spawn = vi.fn(); const spawner = ChildProcessSpawner.make(spawn); - const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.die("unused"), - installWithProgress: () => Effect.die("unused"), - }), - ), - ), + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), + }), ), ); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 73e549ebf49..a1d7112a929 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -9,7 +9,9 @@ import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; @@ -21,23 +23,12 @@ function bytesToString(bytes: Uint8Array): string { const readRuntimeConfig = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if (!bytes) { + if (Option.isNone(bytes)) { return null; } - return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes))); + return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value))); }); -export interface CloudManagedEndpointRuntimeShape { - readonly applyConfig: ( - config: RelayManagedEndpointRuntimeConfig | null, - ) => Effect.Effect; -} - -export class CloudManagedEndpointRuntime extends Context.Service< - CloudManagedEndpointRuntime, - CloudManagedEndpointRuntimeShape ->()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} - export type CloudManagedEndpointRuntimeStatus = | { readonly status: "disabled"; @@ -61,6 +52,15 @@ export type CloudManagedEndpointRuntimeStatus = readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; }; +export class CloudManagedEndpointRuntime extends Context.Service< + CloudManagedEndpointRuntime, + { + readonly applyConfig: ( + config: RelayManagedEndpointRuntimeConfig | null, + ) => Effect.Effect; + } +>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} + interface ActiveConnector { readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; @@ -68,6 +68,13 @@ interface ActiveConnector { readonly config: RelayManagedEndpointRuntimeConfig; } +export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { + if (/\bRegistered tunnel connection\b/iu.test(line)) { + return "connected"; + } + return /\b(?:ERR|WRN)\b/u.test(line) ? "warning" : "debug"; +} + function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { return JSON.stringify({ providerKind: config.providerKind, @@ -89,13 +96,13 @@ const stopConnector = (connector: ActiveConnector | null) => ) : Effect.void; -export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); const reconcileSemaphore = yield* Semaphore.make(1); - let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; + let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { const active = yield* Ref.getAndSet(activeRef, null); @@ -141,6 +148,39 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + const observeConnectorOutput = (connector: ActiveConnector) => + connector.child.all.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map((line) => line.trim()), + Stream.filter((line) => line.length > 0), + Stream.runForEach((line) => { + const output = line.replaceAll(connector.config.connectorToken, ""); + const attributes = { + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + output, + }; + switch (classifyRelayClientOutput(line)) { + case "connected": + return Effect.logInfo("Relay client tunnel connection registered", attributes); + case "warning": + return Effect.logWarning("Relay client reported a transport warning", attributes); + case "debug": + return Effect.logDebug("Relay client output", attributes); + } + }), + Effect.catchCause((cause) => + Effect.logWarning("Relay client output observer failed", { + cause, + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }), + ), + ); + reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { if (!config || config.providerKind !== "cloudflare_tunnel") { yield* stopActive; @@ -190,14 +230,15 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { TUNNEL_TOKEN: config.connectorToken, }, shell: false, - stderr: "ignore", - stdout: "ignore", + stderr: "pipe", + stdout: "pipe", }), ) .pipe( Effect.provideService(Scope.Scope, connectorScope), - Effect.tap(() => - Effect.logInfo("Relay client started", { + Effect.tap((child) => + Effect.logInfo("Relay client process started; waiting for tunnel connection", { + pid: Number(child.pid), tunnelId: config.tunnelId, tunnelName: config.tunnelName, }), @@ -232,6 +273,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { config, } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); + yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); yield* Effect.forkIn(superviseConnector(connector), connectorScope); return { status: "running", @@ -258,24 +300,20 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { ), ); - return CloudManagedEndpointRuntime.of({ + const runtime = CloudManagedEndpointRuntime.of({ applyConfig, }); -}); -export const layer = Layer.effect( - CloudManagedEndpointRuntime, - Effect.gen(function* () { - const runtime = yield* makeCloudManagedEndpointRuntime; - const initialConfig = yield* readRuntimeConfig.pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( - Effect.as(null), - ), + const initialConfig = yield* readRuntimeConfig.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( + Effect.as(null), ), - ); - yield* runtime.applyConfig(initialConfig); - yield* Effect.addFinalizer(() => runtime.applyConfig(null)); - return runtime; - }), -); + ), + ); + yield* runtime.applyConfig(initialConfig); + yield* Effect.addFinalizer(() => runtime.applyConfig(null)); + return runtime; +}); + +export const layer = Layer.effect(CloudManagedEndpointRuntime, make); diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 3a033d50303..48c44ccc48a 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -1,11 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; const makeServerSecretStoreLayer = () => @@ -23,10 +24,10 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it const first = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); const second = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); - expect(second).toEqual(first); - expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); - expect(yield* secretStore.get("cloud-link-ed25519-private-key")).toBeNull(); - expect(yield* secretStore.get("cloud-link-ed25519-public-key")).toBeNull(); + assert.deepEqual(second, first); + assert.isTrue(Option.isSome(yield* secretStore.get("cloud-link-ed25519-key-pair"))); + assert.isTrue(Option.isNone(yield* secretStore.get("cloud-link-ed25519-private-key"))); + assert.isTrue(Option.isNone(yield* secretStore.get("cloud-link-ed25519-public-key"))); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -36,11 +37,11 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it yield* secretStore.set("cloud-link-ed25519-private-key", new TextEncoder().encode("private")); yield* secretStore.set("cloud-link-ed25519-public-key", new TextEncoder().encode("public")); - expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "private", publicKey: "public", }); - expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); + assert.isTrue(Option.isSome(yield* secretStore.get("cloud-link-ed25519-key-pair"))); }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); @@ -53,7 +54,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it const secretStore = { get: (name) => Effect.sync(() => - name === "cloud-link-ed25519-key-pair" && createAttempted ? winner : null, + name === "cloud-link-ed25519-key-pair" && createAttempted + ? Option.some(winner) + : Option.none(), ), set: unusedSecretStoreOperation, create: () => @@ -62,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - message: "Concurrent keypair creation won.", + new ServerSecretStore.SecretStorePersistError({ + resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -76,9 +79,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it ), getOrCreateRandom: unusedSecretStoreOperation, remove: unusedSecretStoreOperation, - } satisfies ServerSecretStore.ServerSecretStoreShape; + } satisfies ServerSecretStore.ServerSecretStore["Service"]; - expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "winner-private", publicKey: "winner-public", }); diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index beef4729992..1d0cde91bf4 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -1,5 +1,6 @@ import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -26,45 +27,47 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = (message: string, cause?: unknown) => - new ServerSecretStore.SecretStoreError({ message, cause }); +const KEY_PAIR_RESOURCE = "environment signing key pair"; + +const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => + new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => + new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => + new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); - if (encoded === null) { - return null; + if (Option.isNone(encoded)) { + return Option.none(); } - return yield* decodeEnvironmentKeyPair(bytesToString(encoded)).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to decode environment signing key pair.", cause), - ), + const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( + Effect.mapError(keyPairDecodeError), ); + return Option.some(decoded); }); const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to encode environment signing key pair.", cause), - ), + Effect.mapError(keyPairEncodeError), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? readEnvironmentKeyPair(secrets).pipe( - Effect.flatMap((existing) => - existing !== null - ? Effect.succeed(existing) - : Effect.fail( - keyPairPersistenceError( - "Failed to read environment signing key pair after concurrent creation.", - ), - ), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => Effect.fail(keyPairConcurrentReadError()), + }), ), ) : Effect.fail(error), @@ -73,19 +76,19 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio }); export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const existing = yield* readEnvironmentKeyPair(secrets); - if (existing !== null) { - return existing; + if (Option.isSome(existing)) { + return existing.value; } const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); - if (existingPrivate && existingPublic) { + if (Option.isSome(existingPrivate) && Option.isSome(existingPublic)) { return yield* persistEnvironmentKeyPair(secrets, { - privateKey: bytesToString(existingPrivate), - publicKey: bytesToString(existingPublic), + privateKey: bytesToString(existingPrivate.value), + publicKey: bytesToString(existingPublic.value), }); } diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 8ea7ca06f9a..ed2e5a4cf75 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -9,21 +9,15 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { - consumeCloudReplayGuards, - reconcileDesiredCloudLink, - traceRelayBrokerHandler, -} from "./http.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist cloud replay guard.", + new ServerSecretStore.SecretStorePersistError({ + resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -35,8 +29,8 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); function makeSecretStore( - create: ServerSecretStore.ServerSecretStoreShape["create"], -): ServerSecretStore.ServerSecretStoreShape { + create: ServerSecretStore.ServerSecretStore["Service"]["create"], +): ServerSecretStore.ServerSecretStore["Service"] { return { get: unusedSecretStoreOperation, set: unusedSecretStoreOperation, @@ -46,6 +40,30 @@ function makeSecretStore( }; } +it("preserves messages surfaced by cloud 500 responses", () => { + const cause = new Error("cloud operation failed"); + + expect([ + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, + ]).toEqual([ + "Could not verify the linked cloud account.", + "Could not read the linked cloud account.", + "Cloud linked user is not installed for this environment.", + "Failed to sign cloud link JWT.", + "Cloud mint public key is not installed for this environment.", + "Cloud relay issuer is not installed for this environment.", + "Failed to sign cloud health JWT.", + "Failed to sign cloud mint JWT.", + ]); +}); + describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { @@ -75,8 +93,38 @@ describe("consumeCloudReplayGuards", () => { ); }); -describe("traceRelayBrokerHandler", () => { - it.effect("continues the incoming relay trace with the product tracer", () => +describe("relay request tracing", () => { + it.effect("does not accept an unauthenticated request trace parent", () => + Effect.gen(function* () { + const spans: Array = []; + const productTracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://environment.example.test/api/t3-cloud/mint-credential", { + headers: { + traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }, + }), + ); + + yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(RelayClientTracer, Option.some(productTracer)), + ); + + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(span.traceId).not.toBe("0123456789abcdef0123456789abcdef"); + expect(Option.isNone(span.parent)).toBe(true); + }), + ); + + it.effect("continues an authenticated relay trace with the product tracer", () => Effect.gen(function* () { const spans: Array = []; const productTracer = Tracer.make({ @@ -94,7 +142,9 @@ describe("traceRelayBrokerHandler", () => { }), ); - yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + yield* traceAuthenticatedRelayRequest( + Effect.void.pipe(Effect.withSpan("relay.mint.handler")), + ).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.provideService(RelayClientTracer, Option.some(productTracer)), ); @@ -122,21 +172,21 @@ describe("reconcileDesiredCloudLink", () => { makeSecretStore(unusedSecretStoreOperation), ), Effect.provideService( - ServerEnvironment, - ServerEnvironment.of({ + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: unusedSecretStoreOperation(), getDescriptor: unusedSecretStoreOperation(), }), ), Effect.provideService( - CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.of({ + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: unusedSecretStoreOperation, - } satisfies CloudManagedEndpointRuntimeShape), + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), ), Effect.provideService( EnvironmentAuth.EnvironmentAuth, - EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuthShape), + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), ), Effect.provideService( CliTokenManager.CloudCliTokenManager, diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index b78d47a20c1..fc2adca9fbc 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -48,21 +48,15 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; -import { - ServerEnvironment, - type ServerEnvironmentShape, -} from "../environment/Services/ServerEnvironment.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, @@ -74,9 +68,10 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import * as CliState from "./CliState.ts"; +import { setCliDesiredCloudLink } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; +import { traceRelayRequest } from "./traceRelayRequest.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -102,6 +97,9 @@ const failEnvironmentCloudInternalError = Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), ); +const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => + failEnvironmentCloudInternalError(error.message)(error); + const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( () => @@ -111,19 +109,6 @@ const requireRelayUrl = relayUrlConfig.pipe( ), ); -export const traceRelayBrokerHandler = ( - effect: Effect.Effect, -): Effect.Effect => - HttpServerRequest.HttpServerRequest.pipe( - Effect.flatMap((request) => - Option.match(HttpTraceContext.fromHeaders(request.headers), { - onNone: () => effect, - onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), - }), - ), - withRelayClientTracing, - ); - function bytesToString(bytes: Uint8Array): string { return new TextDecoder().decode(bytes); } @@ -133,7 +118,7 @@ function stringToBytes(value: string): Uint8Array { } export function consumeCloudReplayGuards(input: { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly names: ReadonlyArray; readonly value: Uint8Array; }) { @@ -141,7 +126,7 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? Effect.succeed(false) : Effect.fail(error), @@ -220,22 +205,21 @@ function validateRelayConfigPayload( } function validateLinkedCloudUser(input: { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly cloudUserId: string; }): Effect.Effect { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not verify the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause, }), ), Effect.flatMap((existing) => { - if (!existing) { + if (Option.isNone(existing)) { return Effect.void; } - const existingCloudUserId = bytesToString(existing); + const existingCloudUserId = bytesToString(existing.value); return existingCloudUserId === input.cloudUserId ? Effect.void : Effect.fail( @@ -249,24 +233,19 @@ function validateLinkedCloudUser(input: { } function readInstalledCloudUserId( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ): Effect.Effect { return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not read the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause, }), ), Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", - }), - ), + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -347,19 +326,19 @@ const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentH const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); interface CloudHttpDependencies { - readonly secrets: ServerSecretStore.ServerSecretStoreShape; - readonly environment: ServerEnvironmentShape; - readonly endpointRuntime: CloudManagedEndpointRuntimeShape; - readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; - readonly cliTokenManager: CliTokenManager.CloudCliTokenManagerShape; + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly endpointRuntime: ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]; + readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; + readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; } const cloudHttpDependencies = Effect.gen(function* () { return { secrets: yield* ServerSecretStore.ServerSecretStore, - environment: yield* ServerEnvironment, - endpointRuntime: yield* CloudManagedEndpointRuntime, + environment: yield* ServerEnvironment.ServerEnvironment, + endpointRuntime: yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime, environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, @@ -409,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud link JWT.", + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause, }), ), @@ -431,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not generate environment link proof."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -492,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), + Effect.catchTag( + "SchemaError", + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - }), ); const relayClientRequest = ( @@ -596,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* CliState.setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -606,12 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpointRuntime: link.endpointRuntime, }); }, + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), + ), Effect.catchTags({ - CloudCliTokenManagerError: (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), + CloudCliCredentialRemovalError: failCloudCliTokenManagerError, + CloudCliCredentialRefreshError: failCloudCliTokenManagerError, + CloudCliCredentialReadError: failCloudCliTokenManagerError, + CloudCliAuthorizationError: failCloudCliTokenManagerError, + CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, }), ); @@ -634,12 +618,12 @@ const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function { concurrency: 4 }, ); return { - linked: cloudUserId !== null, - cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, - relayUrl: relayUrl ? bytesToString(relayUrl) : null, - relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, - publishAgentActivity: publishAgentActivity - ? bytesToString(publishAgentActivity) === "true" + linked: Option.isSome(cloudUserId), + cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, + relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, + relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, + publishAgentActivity: Option.isSome(publishAgentActivity) + ? bytesToString(publishAgentActivity.value) === "true" : false, } satisfies EnvironmentCloudLinkStateResult; }); @@ -649,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not read environment relay configuration."), ), ); @@ -671,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); - yield* CliState.setCliDesiredCloudLink(false); + yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not remove environment relay configuration."), ), ); @@ -692,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), ), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -789,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud health JWT.", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause, }), ), @@ -806,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not answer cloud health request."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -911,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud mint JWT.", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause, }), ), @@ -926,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - }), ); export const connectHttpApiLayer = HttpApiBuilder.group( @@ -953,7 +935,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), + traceRelayRequest(cloudMintCredentialHandler(dependencies, payload)), ); }), ); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 4cce901fa55..c46e2671a46 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import { makeCloudCliOAuthConfig, @@ -88,6 +89,27 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va }).pipe(provideEnv({}), Effect.flip), ); +it.effect("reports malformed Clerk publishable keys as typed configuration failures", () => + Effect.gen(function* () { + const result = yield* makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "pk_test_not-base64!!", + clerkCliOAuthClientIdFallback: "oauth_client_embedded", + }).pipe(provideEnv({}), Effect.result); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.cause._tag, "SourceError"); + if (result.failure.cause._tag === "SourceError") { + assert.equal( + result.failure.cause.message, + "Failed to derive Clerk Frontend API URL from the publishable key.", + ); + assert.instanceOf(result.failure.cause.cause, Error); + } + } + }), +); + it("resolves relay client tracing from runtime config with build-time fallback", () => { const fallback = { tracesUrl: "https://embedded.example.test/v1/traces", diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index b344107d756..176b31d7566 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,6 +1,7 @@ import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -131,16 +132,29 @@ export function makeCloudCliOAuthConfig({ clerkCliOAuthClientIdFallback, ), }).pipe( - Config.map(({ clerkPublishableKey, clientId }) => { - const clerkFrontendApiUrl = clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey); - return { - authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, - tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, - clientId, - redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, - scopes: CLOUD_CLI_OAUTH_SCOPES, - } satisfies CloudCliOAuthConfig; - }), + Config.mapOrFail(({ clerkPublishableKey, clientId }) => + Effect.try({ + try: () => clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey), + catch: (cause) => + new Config.ConfigError( + new ConfigProvider.SourceError({ + message: "Failed to derive Clerk Frontend API URL from the publishable key.", + cause, + }), + ), + }).pipe( + Effect.map( + (clerkFrontendApiUrl) => + ({ + authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, + tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, + clientId, + redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, + scopes: CLOUD_CLI_OAUTH_SCOPES, + }) satisfies CloudCliOAuthConfig, + ), + ), + ), ); } diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts new file mode 100644 index 00000000000..1481b891224 --- /dev/null +++ b/apps/server/src/cloud/traceRelayRequest.ts @@ -0,0 +1,21 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http"; + +export const traceRelayRequest = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe(withRelayClientTracing); + +export const traceAuthenticatedRelayRequest = ( + effect: Effect.Effect, +): Effect.Effect => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => + Option.match(HttpTraceContext.fromHeaders(request.headers), { + onNone: () => effect, + onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), + }), + ), + withRelayClientTracing, + ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..2608ccc16ae 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 18a54326de1..7d16a11c829 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + code: 17, + stdout: "partial process output", + stderr: "process access denied", + }), + ), + ), + ); + + const error = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ProcessDiagnosticsQueryFailedError", + command: "ps", + argCount: 2, + cwd: process.cwd(), + exitCode: 17, + stdoutBytes: 22, + stderrBytes: 21, + stdoutTruncated: false, + stderrTruncated: false, + }); + expect(error.message).toBe( + `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, + ); + }), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index f5f746134f2..b39d560a228 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -12,7 +12,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -31,35 +32,95 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000; const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; -export interface ProcessDiagnosticsShape { - readonly read: Effect.Effect; - readonly signal: (input: { - readonly pid: number; - readonly signal: ServerProcessSignal; - }) => Effect.Effect; -} - export class ProcessDiagnostics extends Context.Service< ProcessDiagnostics, - ProcessDiagnosticsShape + { + readonly read: Effect.Effect; + readonly signal: (input: { + readonly pid: number; + readonly signal: ServerProcessSignal; + }) => Effect.Effect; + } >()("t3/diagnostics/ProcessDiagnostics") {} -class ProcessDiagnosticsError extends Schema.TaggedErrorClass()( - "ProcessDiagnosticsError", +class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsQueryTimeoutError", { - message: Schema.String, + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + timeoutMillis: Schema.Number, + }, +) { + override get message(): string { + return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; + } +} + +class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsQueryFailedError", + { + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutBytes: Schema.optional(Schema.Number), + stderrBytes: Schema.optional(Schema.Number), + stdoutTruncated: Schema.optional(Schema.Boolean), + stderrTruncated: Schema.optional(Schema.Boolean), cause: Schema.optional(Schema.Defect()), }, -) {} -const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +) { + override get message(): string { + const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; + return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`; + } +} -function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { - return new ProcessDiagnosticsError({ - message, - ...(cause === undefined ? {} : { cause }), - }); +class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsServerProcessSignalError", + { pid: Schema.Number }, +) { + override get message(): string { + return "Refusing to signal the T3 server process."; + } } +class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsNotDescendantError", + { + pid: Schema.Number, + serverPid: Schema.Number, + }, +) { + override get message(): string { + return `Process ${this.pid} is not a live descendant of the T3 server.`; + } +} + +class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsSignalFailedError", + { + pid: Schema.Number, + signal: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to signal process ${this.pid} with ${this.signal}.`; + } +} + +const ProcessDiagnosticsError = Schema.Union([ + ProcessDiagnosticsQueryTimeoutError, + ProcessDiagnosticsQueryFailedError, + ProcessDiagnosticsServerProcessSignalError, + ProcessDiagnosticsNotDescendantError, + ProcessDiagnosticsSignalFailedError, +]); +type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; +const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); + function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; @@ -266,24 +327,29 @@ function makeResult(input: { } interface ProcessOutput { + readonly cwd: string; readonly exitCode: number; readonly stdout: string; + readonly stdoutBytes: number; + readonly stdoutTruncated: boolean; readonly stderr: string; + readonly stderrBytes: number; + readonly stderrTruncated: boolean; } -const runProcess = Effect.fn("runProcess")( - function* (input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly errorMessage: string; - }) { +const runProcess = Effect.fn("runProcess")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const cwd = process.cwd(); + return yield* Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; // `ps` and `powershell.exe` are real executables; spawning through cmd.exe // shell mode would re-tokenize the PowerShell `-Command` payload (which // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { - cwd: process.cwd(), + cwd, }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -304,28 +370,44 @@ const runProcess = Effect.fn("runProcess")( ); return { + cwd, exitCode, stdout: stdout.text, + stdoutBytes: stdout.bytes, + stdoutTruncated: stdout.truncated, stderr: stderr.text, + stderrBytes: stderr.bytes, + stderrTruncated: stderr.truncated, } satisfies ProcessOutput; - }, - (effect, input) => - effect.pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : toProcessDiagnosticsError(input.errorMessage, cause), - ), + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new ProcessDiagnosticsQueryTimeoutError({ + command: input.command, + argCount: input.args.length, + cwd, + timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, + }), ), -); + Effect.mapError((cause) => + isProcessDiagnosticsError(cause) + ? cause + : new ProcessDiagnosticsQueryFailedError({ + command: input.command, + argCount: input.args.length, + cwd, + cause, + }), + ), + ); +}); function readPosixProcessRows(): Effect.Effect< ReadonlyArray, @@ -335,11 +417,21 @@ function readPosixProcessRows(): Effect.Effect< return runProcess({ command: "ps", args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], - errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 - ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed.")) + ? Effect.fail( + new ProcessDiagnosticsQueryFailedError({ + command: "ps", + argCount: 2, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + }), + ) : Effect.succeed(parsePosixProcessRows(result.stdout)), ), ); @@ -361,12 +453,20 @@ function readWindowsProcessRows(): Effect.Effect< return runProcess({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], - errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 ? Effect.fail( - toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."), + new ProcessDiagnosticsQueryFailedError({ + command: "powershell.exe", + argCount: 4, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + }), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), ), @@ -390,7 +490,11 @@ function assertDescendantPid( pid: number, ): Effect.Effect { if (pid === process.pid) { - return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); + return Effect.fail( + new ProcessDiagnosticsServerProcessSignalError({ + pid, + }), + ); } return readProcessRows.pipe( @@ -402,16 +506,19 @@ function assertDescendantPid( return descendant ? Effect.void : Effect.fail( - toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), + new ProcessDiagnosticsNotDescendantError({ + pid, + serverPid: process.pid, + }), ); }), ); } -export const make = Effect.fn("makeProcessDiagnostics")(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { + const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () { const readAt = yield* DateTime.now; const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -427,7 +534,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { ), ); - const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")( + const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")( function* (input) { return yield* assertDescendantPid(input.pid).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -443,10 +550,11 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { }; }, catch: (cause) => - toProcessDiagnosticsError( - `Failed to signal process ${input.pid} with ${input.signal}.`, + new ProcessDiagnosticsSignalFailedError({ + pid: input.pid, + signal: input.signal, cause, - ), + }), }), ), Effect.catch((error: ProcessDiagnosticsError) => @@ -464,4 +572,4 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { return ProcessDiagnostics.of({ read, signal }); }); -export const layer = Layer.effect(ProcessDiagnostics, make()); +export const layer = Layer.effect(ProcessDiagnostics, make); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index 11d12c012db..d9c4eb06ef1 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -3,16 +3,13 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { - aggregateProcessResourceHistory, - collectMonitoredSamples, -} from "./ProcessResourceMonitor.ts"; +import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts"; describe("ProcessResourceMonitor", () => { it.effect("samples the server root process and descendants", () => Effect.sync(() => { const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = collectMonitoredSamples({ + const samples = ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt, sampledAtMs: DateTime.toEpochMillis(sampledAt), @@ -72,7 +69,7 @@ describe("ProcessResourceMonitor", () => { const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z"); const samples = [ - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: firstAt, sampledAtMs: DateTime.toEpochMillis(firstAt), @@ -89,7 +86,7 @@ describe("ProcessResourceMonitor", () => { }, ], }), - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: secondAt, sampledAtMs: DateTime.toEpochMillis(secondAt), @@ -108,13 +105,13 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(Option.isNone(result.error)).toBe(true); @@ -132,7 +129,7 @@ describe("ProcessResourceMonitor", () => { const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z"); const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z"); const samples = [ - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: firstAt, sampledAtMs: DateTime.toEpochMillis(firstAt), @@ -149,7 +146,7 @@ describe("ProcessResourceMonitor", () => { }, ], }), - ...collectMonitoredSamples({ + ...ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt: secondAt, sampledAtMs: DateTime.toEpochMillis(secondAt), @@ -168,13 +165,13 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(1); @@ -187,7 +184,7 @@ describe("ProcessResourceMonitor", () => { it.effect("returns all process summaries in the selected window", () => Effect.sync(() => { const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); - const samples = collectMonitoredSamples({ + const samples = ProcessResourceMonitor.collectMonitoredSamples({ serverPid: 100, sampledAt, sampledAtMs: DateTime.toEpochMillis(sampledAt), @@ -215,17 +212,44 @@ describe("ProcessResourceMonitor", () => { ], }); - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: sampledAt, readAtMs: DateTime.toEpochMillis(sampledAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(36); expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); }), ); + + it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => + Effect.sync(() => { + const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const cause = new Error("stderr included credential=secret-value"); + const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ + failureTag: "ProcessDiagnosticsQueryFailedError", + cause, + }); + + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ + samples: [], + readAt, + readAtMs: DateTime.toEpochMillis(readAt), + windowMs: 60_000, + bucketMs: 10_000, + lastFailure: failure, + }); + + expect(failure.cause).toBe(cause); + expect(Option.getOrThrow(result.error)).toEqual({ + failureTag: "ProcessDiagnosticsQueryFailedError", + message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", + }); + expect(Option.getOrThrow(result.error).message).not.toContain("secret-value"); + }), + ); }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index efeeb66256d..6030e4172e1 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -1,8 +1,10 @@ -import type { - ServerProcessResourceHistoryBucket, - ServerProcessResourceHistoryInput, - ServerProcessResourceHistoryResult, - ServerProcessResourceHistorySummary, +import { + ServerProcessResourceHistoryFailureTag, + type ServerProcessResourceHistoryBucket, + type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, + type ServerProcessResourceHistoryInput, + type ServerProcessResourceHistoryResult, + type ServerProcessResourceHistorySummary, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -10,14 +12,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - buildDescendantEntries, - isDiagnosticsQueryProcess, - type ProcessRow, - readProcessRows, -} from "./ProcessDiagnostics.ts"; +import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; const SAMPLE_INTERVAL_MS = 5_000; const RETENTION_MS = 60 * 60_000; @@ -36,43 +34,58 @@ export interface ProcessResourceSample { readonly isServerRoot: boolean; } -interface MonitorState { - readonly samples: ReadonlyArray; - readonly lastError: string | null; +export class ProcessResourceSamplingError extends Schema.TaggedErrorClass()( + "ProcessResourceSamplingError", + { + failureTag: ServerProcessResourceHistoryFailureTag, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sample process resources (${this.failureTag}).`; + } } -export interface ProcessResourceMonitorShape { - readonly readHistory: ( - input: ServerProcessResourceHistoryInput, - ) => Effect.Effect; +interface MonitorState { + readonly samples: ReadonlyArray; + readonly lastFailure: ProcessResourceSamplingError | null; } export class ProcessResourceMonitor extends Context.Service< ProcessResourceMonitor, - ProcessResourceMonitorShape + { + readonly readHistory: ( + input: ServerProcessResourceHistoryInput, + ) => Effect.Effect; + } >()("t3/diagnostics/ProcessResourceMonitor") {} function dateTimeFromMillis(ms: number): DateTime.Utc { return DateTime.makeUnsafe(ms); } -function sampleKey(row: Pick): string { +function sampleKey(row: Pick): string { return `${row.pid}:${row.command}`; } -function findServerRootRow(rows: ReadonlyArray, serverPid: number): ProcessRow | null { +function findServerRootRow( + rows: ReadonlyArray, + serverPid: number, +): ProcessDiagnostics.ProcessRow | null { return rows.find((row) => row.pid === serverPid) ?? null; } export function collectMonitoredSamples(input: { - readonly rows: ReadonlyArray; + readonly rows: ReadonlyArray; readonly serverPid: number; readonly sampledAt: DateTime.Utc; readonly sampledAtMs: number; }): ReadonlyArray { - const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); + const rows = input.rows.filter( + (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid), + ); const root = findServerRootRow(rows, input.serverPid); - const descendants = buildDescendantEntries(rows, input.serverPid); + const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid); const samples: ProcessResourceSample[] = []; if (root) { @@ -220,7 +233,7 @@ export function aggregateProcessResourceHistory(input: { readonly readAtMs: number; readonly windowMs: number; readonly bucketMs: number; - readonly lastError: string | null; + readonly lastFailure: ProcessResourceSamplingError | null; }): ServerProcessResourceHistoryResult { const windowMs = Math.max(1_000, input.windowMs); const bucketMs = Math.max(1_000, input.bucketMs); @@ -241,18 +254,34 @@ export function aggregateProcessResourceHistory(input: { totalCpuSecondsApprox, buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), topProcesses, - error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(), + error: input.lastFailure + ? Option.some({ + failureTag: input.lastFailure.failureTag, + message: input.lastFailure.message, + }) + : Option.none(), }; } -export const make = Effect.fn("makeProcessResourceMonitor")(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const state = yield* Ref.make({ samples: [], lastError: null }); + const state = yield* Ref.make({ samples: [], lastFailure: null }); + + const recordSamplingFailure = (cause: { + readonly _tag: ServerProcessResourceHistoryFailureTagType; + }) => + Ref.update(state, (current) => ({ + ...current, + lastFailure: new ProcessResourceSamplingError({ + failureTag: cause._tag, + cause, + }), + })); const sampleOnce = Effect.gen(function* () { const sampledAt = yield* DateTime.now; const sampledAtMs = DateTime.toEpochMillis(sampledAt); - const rows = yield* readProcessRows.pipe( + const rows = yield* ProcessDiagnostics.readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const samples = collectMonitoredSamples({ @@ -263,22 +292,23 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { }); yield* Ref.update(state, (current) => ({ samples: trimSamples([...current.samples, ...samples], sampledAtMs), - lastError: null, + lastFailure: null, })); }).pipe( - Effect.catch((error: unknown) => - Ref.update(state, (current) => ({ - ...current, - lastError: error instanceof Error ? error.message : "Failed to sample process resources.", - })), - ), + Effect.catchTags({ + ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure, + ProcessDiagnosticsQueryFailedError: recordSamplingFailure, + ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure, + ProcessDiagnosticsNotDescendantError: recordSamplingFailure, + ProcessDiagnosticsSignalFailedError: recordSamplingFailure, + }), ); yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( Effect.forkScoped, ); - const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) => + const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => Effect.gen(function* () { const readAt = yield* DateTime.now; const readAtMs = DateTime.toEpochMillis(readAt); @@ -289,11 +319,11 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { readAtMs, windowMs: input.windowMs, bucketMs: input.bucketMs, - lastError: current.lastError, + lastFailure: current.lastFailure, }); }); return ProcessResourceMonitor.of({ readHistory }); }); -export const layer = Layer.effect(ProcessResourceMonitor, make()); +export const layer = Layer.effect(ProcessResourceMonitor, make); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index ff63410b9bc..d396f4e4ee9 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -39,13 +39,14 @@ export interface TraceDiagnosticsOptions { readonly readAt?: DateTime.Utc; } -export interface TraceDiagnosticsShape { - readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect; -} - -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; @@ -395,10 +396,10 @@ function readTraceFile( ); } -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; @@ -449,7 +450,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 6904c53c847..00000000000 --- 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 fd4f6baab1a..00000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { ServerConfig } from "../../config.ts"; -import { layer as ProcessRunnerLive } from "../../processRunner.ts"; -import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; -import packageJson from "../../../package.json" with { type: "json" }; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; - -function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (platform) { - case "darwin": - return "darwin"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -function platformArch( - architecture: NodeJS.Architecture, -): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (architecture) { - case "arm64": - return "arm64"; - case "x64": - return "x64"; - default: - return "other"; - } -} - -export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; - const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; - - const readPersistedEnvironmentId = Effect.gen(function* () { - const exists = yield* fileSystem - .exists(serverConfig.environmentIdPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - const raw = yield* fileSystem - .readFileString(serverConfig.environmentIdPath) - .pipe(Effect.map((value) => value.trim())); - - return raw.length > 0 ? raw : null; - }); - - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); - - const environmentIdRaw = yield* Effect.gen(function* () { - const persisted = yield* readPersistedEnvironmentId; - if (persisted) { - return persisted; - } - - const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; - }); - - const environmentId = EnvironmentId.make(environmentIdRaw); - const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ - cwdBaseName, - }); - - const descriptor: ExecutionEnvironmentDescriptor = { - environmentId, - label, - platform: { - os: platformOs(hostPlatform), - arch: platformArch(hostArchitecture), - }, - serverVersion: packageJson.version, - capabilities: { - repositoryIdentity: true, - }, - }; - - return { - getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), - } satisfies ServerEnvironmentShape; -}); - -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( - Layer.provide(ProcessRunnerLive), -); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts new file mode 100644 index 00000000000..6b3290246fe --- /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 00000000000..b5fbd8e1088 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -0,0 +1,152 @@ +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( + "ServerEnvironmentIdPersistenceError", + { + operation: Schema.Literals(["check", "read", "write"]), + environmentIdPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; + } +} + +export class ServerEnvironment extends Context.Service< + ServerEnvironment, + { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; + } +>()("t3/environment/ServerEnvironment") {} + +function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch( + architecture: NodeJS.Architecture, +): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (architecture) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "check", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + if (!exists) { + return null; + } + + const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( + Effect.map((value) => value.trim()), + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "read", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* crypto.randomUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.make(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(hostPlatform), + arch: platformArch(hostArchitecture), + }, + serverVersion: packageJson.version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }); +}); + +/** + * ServerEnvironment is acquired from persisted filesystem and host-process + * state. It intentionally has no fallback Layer.succeed value: callers must + * provide the external platform services and a ServerConfig. + */ +export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts similarity index 93% rename from apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts rename to apps/server/src/environment/ServerEnvironmentLabel.test.ts index 3a4dce1627c..4bc9647fba5 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -2,18 +2,18 @@ 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 ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; -import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; -const runMock = vi.fn(); +const runMock = vi.fn(); const ProcessRunnerTest = Layer.succeed( - ProcessRunner, - ProcessRunner.of({ + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input), }), ); @@ -136,9 +136,9 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { runMock.mockReturnValueOnce( Effect.fail( - new ProcessSpawnError({ + new ProcessRunner.ProcessSpawnError({ command: "scutil", - args: ["--get", "ComputerName"], + argumentCount: 2, cause: new Error("spawn scutil ENOENT"), }), ), diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts similarity index 96% rename from apps/server/src/environment/Layers/ServerEnvironmentLabel.ts rename to apps/server/src/environment/ServerEnvironmentLabel.ts index 73a3b9526c4..83c3b8bad8e 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import { ProcessRunner } from "../../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; @@ -50,7 +50,7 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( command: string, args: readonly string[], ) { - const processRunner = yield* ProcessRunner; + const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner .run({ command, diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts deleted file mode 100644 index 1e6dea0d05f..00000000000 --- a/apps/server/src/environment/Services/ServerEnvironment.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface ServerEnvironmentShape { - readonly getEnvironmentId: Effect.Effect; - readonly getDescriptor: Effect.Effect; -} - -export class ServerEnvironment extends Context.Service()( - "t3/environment/Services/ServerEnvironment", -) {} diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bee861677a5..81b82d7de30 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,20 +164,20 @@ 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({ + throw new GitHubCli.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 { +function isGitHubCliError(error: unknown): error is GitHubCli.GitHubCliError { return ( typeof error === "object" && error !== null && @@ -265,7 +254,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 +301,9 @@ function configureVisibleRemoteUrlWithLocalRewrite( }); } -function createTextGeneration(overrides: Partial = {}): TextGenerationShape { +function createTextGeneration( + overrides: Partial = {}, +): TextGeneration.TextGeneration["Service"] { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => Effect.succeed({ @@ -385,7 +376,7 @@ function createTextGeneration(overrides: Partial = {}): T } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCliShape; + service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -397,7 +388,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 +459,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}`], { @@ -487,7 +478,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { catch: (error) => isGitHubCliError(error) ? error - : new GitHubCliError({ + : new GitHubCli.GitHubCliError({ operation: "execute", detail: error instanceof Error @@ -503,7 +494,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCliError({ + new GitHubCli.GitHubCliError({ operation: "execute", detail: `Unexpected repository lookup: ${repository}`, }), @@ -523,7 +514,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCliError({ + new GitHubCli.GitHubCliError({ operation: "execute", detail: `Unexpected gh command: ${args.join(" ")}`, }), @@ -553,7 +544,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 +583,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,7 +593,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCliError({ + new GitHubCli.GitHubCliError({ operation: "createRepository", detail: `Unexpected repository create: ${input.repository}`, }), @@ -616,7 +609,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 +618,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -636,12 +629,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 +646,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 +672,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 +688,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 +916,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 +1026,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,7 +1331,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHubCli.GitHubCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), @@ -1352,7 +1348,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 +1383,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 +1426,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 +1454,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 +1514,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({ @@ -1601,7 +1597,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 +1626,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 +1697,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 +1725,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 +1760,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 +1805,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 +1982,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 +2200,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 +2239,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 +2304,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 +2376,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,7 +2469,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHubCli.GitHubCliError({ operation: "execute", detail: "GitHub CLI (`gh`) is required but not available on PATH.", }), @@ -2446,7 +2498,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCliError({ + failWith: new GitHubCli.GitHubCliError({ operation: "execute", detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }), @@ -2504,7 +2556,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 +2597,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 +2655,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 +2702,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 +2730,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", @@ -2695,14 +2747,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 +2802,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 +2864,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 +2917,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 +2969,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 +3008,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 +3028,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 +3088,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 +3146,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 +3187,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 +3204,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 +3223,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 +3244,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 +3284,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 +3347,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 }, ); @@ -3333,7 +3400,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "pr only\n"); yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 99121182713..88eb0e21282 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -42,13 +42,13 @@ import { } from "@t3tools/shared/sourceControl"; import { GitManagerError } from "@t3tools/contracts"; -import { TextGeneration } from "../textGeneration/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +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; @@ -526,15 +526,15 @@ 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 serverSettingsService = yield* ServerSettings.ServerSettingsService; const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError((cause) => gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), @@ -716,7 +716,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 +745,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 +781,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 +1092,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; @@ -1295,7 +1319,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, @@ -1350,53 +1375,58 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); - const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - return yield* Cache.get(localStatusResultCache, cacheKey); - }); - const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); + }, + ); + const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input, options) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + if (options?.refreshUpstream === false) { + return yield* readRemoteStatus(cacheKey, options); + } return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); - const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { + const status: GitManager["Service"]["status"] = Effect.fn("status")(function* (input) { const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { concurrency: "unbounded", }); return mergeGitStatusParts(local, remote); }); - const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); }); - const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + const invalidateRemoteStatus: GitManager["Service"]["invalidateRemoteStatus"] = Effect.fn( "invalidateRemoteStatus", )(function* (cwd) { yield* invalidateRemoteStatusResultCache(cwd); }); - const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + const invalidateStatus: GitManager["Service"]["invalidateStatus"] = Effect.fn("invalidateStatus")( function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); }, ); - const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( - function* (input) { - const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) - .getChangeRequest({ - cwd: input.cwd, - reference: normalizePullRequestReference(input.reference), - }) - .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( + "resolvePullRequest", + )(function* (input) { + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); - return { pullRequest }; - }, - ); + return { pullRequest }; + }); - const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { const maybeRunSetupScript = (worktreePath: string) => { @@ -1597,7 +1627,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()); @@ -1776,7 +1806,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - return { + return GitManager.of({ localStatus, remoteStatus, status, @@ -1786,7 +1816,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { resolvePullRequest, preparePullRequestThread, runStackedAction, - } satisfies GitManagerShape; + }); }); -export const layer = Layer.effect(GitManager, makeGitManager()); +export const layer = Layer.effect(GitManager, make); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 9a34680496f..03cd624600d 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -7,7 +7,9 @@ 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)({ diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..f958b663006 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -28,55 +28,70 @@ 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) => @@ -129,10 +144,10 @@ 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, @@ -259,10 +274,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 +309,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 +336,4 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { }); }); -export const layer = Layer.effect(GitWorkflowService, make()); +export const layer = Layer.effect(GitWorkflowService, make); diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index b414daaa0a4..e4a703f4454 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; export function isGitRepository(cwd: string): boolean { - return existsSync(join(cwd, ".git")); + return NodeFS.existsSync(NodePath.join(cwd, ".git")); } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 5197ad34296..ce9b498cb1f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,21 +24,22 @@ import { import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; -import { resolveStaticDir, ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, FALLBACK_PROJECT_FAVICON_SVG, resolveAsset, } from "./assets/AssetAccess.ts"; -import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; +import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, failEnvironmentScopeRequired, failEnvironmentAuthInvalid, failEnvironmentInternal, } from "./auth/http.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; @@ -46,7 +47,7 @@ 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 } : {}), @@ -80,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); @@ -94,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), ); }), ); @@ -116,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); @@ -222,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 1bfd042d078..ba95422735c 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+["); @@ -215,17 +208,17 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); const configState = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.loadConfigState; }); assert.deepEqual( configState.keybindings, - compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS), + Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS), ); assert.deepEqual(configState.issues, [ { @@ -240,7 +233,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("ignores invalid entries in runtime and reports them as issues", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -252,7 +245,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); const configState = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.loadConfigState; }); @@ -279,14 +272,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { "upserts missing default keybindings on startup without overriding existing command rules", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+shift+t", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.syncDefaultKeybindingsOnStartup; }); @@ -300,7 +293,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), ); - for (const defaultRule of DEFAULT_KEYBINDINGS) { + for (const defaultRule of Keybindings.DEFAULT_KEYBINDINGS) { assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); } assert.isTrue(byCommand.has("script.run-tests.run")); @@ -314,13 +307,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); return Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "script.custom-action.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.syncDefaultKeybindingsOnStartup; }); @@ -345,13 +338,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("upserts custom keybindings to configured path", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const resolved = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -371,12 +364,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -394,13 +387,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("replaces only the targeted custom keybinding", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+alt+r", command: "script.run-tests.run", @@ -419,13 +412,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+r", command: "script.run-tests.run" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.removeKeybindingRule({ key: "mod+r", command: "script.run-tests.run", @@ -441,11 +434,11 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -461,14 +454,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("reports non-array config parse errors without duplicate prefix", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, '{"key":"mod+j","command":"terminal.toggle"}', ); const firstResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -477,7 +470,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assertFailure(firstResult, "expected JSON array"); const secondResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -490,7 +483,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("fails when config directory is not writable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const { dirname } = yield* Path.Path; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, @@ -498,7 +491,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; return yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", @@ -516,13 +509,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("caches loaded resolved config across repeated reads", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const [first, second] = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; const firstLoad = (yield* keybindings.loadConfigState).keybindings; const secondLoad = (yield* keybindings.loadConfigState).keybindings; return [firstLoad, secondLoad] as const; @@ -535,13 +528,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("updates cached resolved config after upsert", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, ]); const loadedAfterUpsert = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* keybindings.loadConfigState; yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", @@ -557,7 +550,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("serializes concurrent upserts to avoid lost updates", () => Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, []); const commands = Array.from( @@ -565,7 +558,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { (_, index): KeybindingCommand => `script.concurrent-${index}.run`, ); yield* Effect.gen(function* () { - const keybindings = yield* Keybindings; + const keybindings = yield* Keybindings.Keybindings; yield* Effect.all( commands.map((command, index) => keybindings.upsertKeybindingRule({ diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 80b522eee71..5ddae4943f8 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -41,7 +41,7 @@ import * as Context from "effect/Context"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { @@ -225,74 +225,70 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve return merged.slice(-MAX_KEYBINDINGS_COUNT); } -/** - * KeybindingsShape - Service API for keybinding configuration operations. - */ -export interface KeybindingsShape { - /** - * Start the keybindings runtime and attach file watching. - * - * Safe to call multiple times. The first successful call establishes the - * runtime; later calls await the same startup. - */ - readonly start: Effect.Effect; - - /** - * Await keybindings runtime readiness. - * - * Readiness means the config directory exists, the watcher is attached, the - * startup sync has completed, and the current snapshot has been loaded. - */ - readonly ready: Effect.Effect; - - /** - * Ensure the on-disk keybindings file exists and includes all default - * commands so newly-added defaults are backfilled on startup. - */ - readonly syncDefaultKeybindingsOnStartup: Effect.Effect; - - /** - * Load runtime keybindings state along with non-fatal configuration issues. - */ - readonly loadConfigState: Effect.Effect; - - /** - * Read the latest keybindings snapshot from cache/disk. - */ - readonly getSnapshot: Effect.Effect; - - /** - * Stream of keybindings config change events. - */ - readonly streamChanges: Stream.Stream; - - /** - * Upsert a keybinding rule and persist the resulting configuration. - * - * Writes config atomically and enforces the max rule count by truncating - * oldest entries when needed. - */ - readonly upsertKeybindingRule: ( - input: ServerUpsertKeybindingInput, - ) => Effect.Effect; - - /** - * Remove a single persisted keybinding rule by exact key/command/when match. - */ - readonly removeKeybindingRule: ( - input: ServerRemoveKeybindingInput, - ) => Effect.Effect; -} - /** * Keybindings - Service tag for keybinding configuration operations. */ -export class Keybindings extends Context.Service()( - "t3/keybindings", -) {} +export class Keybindings extends Context.Service< + Keybindings, + { + /** + * Start the keybindings runtime and attach file watching. + * + * Safe to call multiple times. The first successful call establishes the + * runtime; later calls await the same startup. + */ + readonly start: Effect.Effect; + + /** + * Await keybindings runtime readiness. + * + * Readiness means the config directory exists, the watcher is attached, the + * startup sync has completed, and the current snapshot has been loaded. + */ + readonly ready: Effect.Effect; + + /** + * Ensure the on-disk keybindings file exists and includes all default + * commands so newly-added defaults are backfilled on startup. + */ + readonly syncDefaultKeybindingsOnStartup: Effect.Effect; + + /** + * Load runtime keybindings state along with non-fatal configuration issues. + */ + readonly loadConfigState: Effect.Effect; + + /** + * Read the latest keybindings snapshot from cache/disk. + */ + readonly getSnapshot: Effect.Effect; + + /** + * Stream of keybindings config change events. + */ + readonly streamChanges: Stream.Stream; + + /** + * Upsert a keybinding rule and persist the resulting configuration. + * + * Writes config atomically and enforces the max rule count by truncating + * oldest entries when needed. + */ + readonly upsertKeybindingRule: ( + input: ServerUpsertKeybindingInput, + ) => Effect.Effect; + + /** + * Remove a single persisted keybinding rule by exact key/command/when match. + */ + readonly removeKeybindingRule: ( + input: ServerRemoveKeybindingInput, + ) => Effect.Effect; + } +>()("t3/keybindings") {} -const makeKeybindings = Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig; +const make = Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const upsertSemaphore = yield* Semaphore.make(1); @@ -700,7 +696,7 @@ const makeKeybindings = Effect.gen(function* () { return nextResolved; }), ), - } satisfies KeybindingsShape; + } satisfies Keybindings["Service"]; }); -export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings); +export const layer = Layer.effect(Keybindings, make); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 25509dc593f..f550396c660 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -49,6 +49,62 @@ it("normalizes empty successful notification responses to accepted", () => { expect(resultResponse.status).toBe(200); }); +it.effect("returns bounded structural preview snapshot failures", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: "sensitive renderer failure", + detail: { consoleOutput: "sensitive browser output" }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); + expect(snapshot.structuredContent).toEqual({ + error: { + _tag: "PreviewAutomationExecutionError", + operation: "snapshot", + failureCount: 1, + }, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { @@ -107,7 +163,15 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const requests = yield* broker.connect("mcp-test-client"); + const requests = yield* broker.connect({ + clientId: "mcp-test-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6cde2017a9e..e95662a30f8 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; +const previewSnapshotFailure = (cause: Cause.Cause) => { + if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { + return Effect.failCause(cause).pipe(Effect.orDie); + } + const failures = cause.reasons.filter(Cause.isFailReason); + const firstFailure = failures[0]?.error; + const errorTag = + typeof firstFailure === "object" && + firstFailure !== null && + "_tag" in firstFailure && + typeof firstFailure._tag === "string" + ? firstFailure._tag + : "PreviewSnapshotError"; + const result = new McpSchema.CallToolResult({ + isError: true, + structuredContent: { + error: { + _tag: errorTag, + operation: "snapshot", + failureCount: failures.length, + }, + }, + content: [{ type: "text", text: "Preview snapshot failed." }], + }); + return Effect.logWarning("preview snapshot failed", { + operation: "snapshot", + errorTag, + failureCount: failures.length, + }).pipe(Effect.as(result)); +}; + const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCause({ - onFailure: (cause) => - new McpSchema.CallToolResult({ - isError: true, - content: [{ type: "text", text: Cause.pretty(cause) }], - }), + Effect.matchCauseEffect({ + onFailure: previewSnapshotFailure, onSuccess: ({ encodedResult }) => { const snapshot = encodedResult as { readonly screenshot: { @@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot height: screenshot.height, }, }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); + return Effect.succeed( + new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }), + ); }, }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index d6540d567af..a91d98febd8 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -4,20 +4,22 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; const environmentId = EnvironmentId.make("environment-1"); -const fakeHttpServer = HttpServer.HttpServer.of({ - address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, - serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], -}); -const fakeEnvironment = ServerEnvironment.of({ +const makeFakeHttpServer = (hostname: string, port = 43123) => + HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname, port }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }); +const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); +const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), }); -const makeRegistry = (now: () => number) => +const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => McpSessionRegistry.__testing .make({ now, @@ -25,8 +27,8 @@ const makeRegistry = (now: () => number) => maximumLifetimeMs: 1_000, }) .pipe( - Effect.provideService(HttpServer.HttpServer, fakeHttpServer), - Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(HttpServer.HttpServer, httpServer), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), Effect.provide(NodeServices.layer), ); @@ -53,6 +55,26 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("builds MCP endpoints from the bound server host", () => + Effect.gen(function* () { + const cases = [ + ["100.64.0.40", "http://100.64.0.40:43123/mcp"], + ["0.0.0.0", "http://127.0.0.1:43123/mcp"], + ["localhost", "http://localhost:43123/mcp"], + ["127.0.0.1", "http://127.0.0.1:43123/mcp"], + ] as const; + + for (const [hostname, expectedEndpoint] of cases) { + const registry = yield* makeRegistry(() => 1_000, makeFakeHttpServer(hostname)); + const issued = yield* registry.issue({ + threadId: ThreadId.make(`thread-${hostname}`), + providerInstanceId: ProviderInstanceId.make("codex"), + }); + expect(issued.config.endpoint).toBe(expectedEndpoint); + } + }), +); + it.effect("expires credentials after inactivity", () => Effect.gen(function* () { let timestamp = 1_000; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 1ee7d278c62..67c4f2f0ff0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpServer } from "effect/unstable/http"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpProviderSession from "./McpProviderSession.ts"; @@ -60,11 +60,22 @@ const bytesToHex = (bytes: Uint8Array): string => const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); +const getHttpMcpEndpointHost = (hostname: string): string => { + const normalized = hostname.toLowerCase(); + const endpointHostname = + normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]" + ? "127.0.0.1" + : hostname; + return endpointHostname.includes(":") && !endpointHostname.startsWith("[") + ? `[${endpointHostname}]` + : endpointHostname; +}; + const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( options: McpSessionRegistryOptions = {}, ) { const crypto = yield* Crypto.Crypto; - const environment = yield* ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironment; const environmentId = yield* environment.getEnvironmentId; const httpServer = yield* HttpServer.HttpServer; const state = yield* SynchronizedRef.make({ records: new Map() }); @@ -73,7 +84,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; const endpoint = httpServer.address._tag === "TcpAddress" - ? `http://127.0.0.1:${httpServer.address.port}/mcp` + ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` : "http://127.0.0.1/mcp"; const hashToken = (token: string) => @@ -180,11 +191,7 @@ const make = Effect.acquireRelease( }), ); -export const layer: Layer.Layer< - McpSessionRegistry, - never, - Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer -> = Layer.effect(McpSessionRegistry, make); +export const layer = Layer.effect(McpSessionRegistry, make); export const issueActiveMcpCredential = ( request: McpCredentialRequest, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 353353aaef2..5631b3bef57 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -2,10 +2,13 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, PreviewAutomationNoFocusedOwnerError, + PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, + type PreviewAutomationOwner, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -20,11 +23,22 @@ const scope = { expiresAt: 2, }; -it.effect("routes a request to the focused owner and correlates its response", () => +const makeOwner = (overrides: Partial = {}): PreviewAutomationOwner => ({ + clientId: "client-1", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: null, + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + ...overrides, +}); + +it.effect("atomically registers a connected owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; - const requests = yield* broker.connect("client-1"); + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, @@ -33,15 +47,6 @@ it.effect("routes a request to the focused owner and correlates its response", ( }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "client-1", - environmentId: scope.environmentId, - threadId: scope.threadId, - tabId: null, - visible: false, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", - }); const result = yield* broker.invoke<{ available: boolean }>({ scope, @@ -56,7 +61,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); @@ -67,23 +72,120 @@ it.effect("rejects calls when no focused owner exists", () => it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; - const requests = yield* broker.connect("client-hidden"); + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect( + makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), + ); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "client-hidden", + + 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: scope.threadId, - tabId: "tab-hidden", - visible: false, - supportsAutomation: true, - focusedAt: "2026-06-11T00:00:00.000Z", + threadId: ThreadId.make("thread-stale"), }); - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + 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(PreviewAutomationUnavailableError); + }), + ), +); + +it.effect("falls back to an older connected owner when a newer report is not connected", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" })); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true, result: "connected" }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner( + makeOwner({ + clientId: "client-report-only", + focusedAt: "2026-06-11T00:00:01.000Z", + }), + ); + + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + + expect(result).toBe("connected"); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index e0a7b0c9285..ee9d5bdbd0d 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -11,6 +11,7 @@ import { type PreviewAutomationError, type PreviewAutomationOperation, type PreviewAutomationOwner, + type PreviewAutomationOwnerIdentity, type PreviewAutomationRequest, type PreviewAutomationResponse, type PreviewTabId, @@ -34,36 +35,32 @@ export interface PreviewAutomationInvokeInput { readonly timeoutMs?: number; } -export interface PreviewAutomationBrokerShape { - readonly connect: (clientId: string) => Effect.Effect>; - readonly reportOwner: ( - owner: PreviewAutomationOwner, - ) => Effect.Effect; - readonly clearOwner: (clientId: string) => Effect.Effect; - readonly respond: ( - response: PreviewAutomationResponse, - ) => Effect.Effect; - readonly invoke: ( - request: PreviewAutomationInvokeInput, - ) => Effect.Effect; -} - export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, - PreviewAutomationBrokerShape + { + readonly connect: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect>; + readonly reportOwner: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect; + readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; + readonly respond: ( + response: PreviewAutomationResponse, + ) => Effect.Effect; + readonly invoke: ( + request: PreviewAutomationInvokeInput, + ) => Effect.Effect; + } >()("t3/mcp/PreviewAutomationBroker") {} interface ClientConnection { readonly clientId: string; - readonly queue: Queue.Queue< - Parameters[0] extends never - ? never - : import("@t3tools/contracts").PreviewAutomationRequest - >; + readonly queue: Queue.Queue; } interface PendingRequest { - readonly clientId: string; + readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; } @@ -120,7 +117,7 @@ const makeResponseError = ( } }; -const make = Effect.gen(function* PreviewAutomationBrokerMake() { +export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const state = yield* SynchronizedRef.make({ clients: new Map(), owners: new Map(), @@ -133,17 +130,16 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { queue: ClientConnection["queue"], ) { const toFail = yield* SynchronizedRef.modify(state, (current) => { - if (current.clients.get(clientId)?.queue !== queue) { - return [[] as ReadonlyArray, current] as const; - } const clients = new Map(current.clients); const owners = new Map(current.owners); const pending = new Map(current.pending); const disconnected: PendingRequest[] = []; - clients.delete(clientId); - owners.delete(clientId); + if (current.clients.get(clientId)?.queue === queue) { + clients.delete(clientId); + owners.delete(clientId); + } for (const [requestId, entry] of pending) { - if (entry.clientId === clientId) { + if (entry.queue === queue) { pending.delete(requestId); disconnected.push(entry); } @@ -164,20 +160,30 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Queue.shutdown(queue); }); - const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn( + const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( "PreviewAutomationBroker.connect", - )(function* (clientId) { + )(function* (owner) { + const clientId = owner.clientId; const queue = yield* Queue.unbounded(); const previous = yield* SynchronizedRef.modify(state, (current) => { const clients = new Map(current.clients); + const owners = new Map(current.owners); + const existingOwner = current.owners.get(clientId); clients.set(clientId, { clientId, queue }); - return [current.clients.get(clientId), { ...current, clients }] as const; + owners.set( + clientId, + existingOwner?.environmentId === owner.environmentId && + existingOwner.threadId === owner.threadId + ? { ...existingOwner, supportsAutomation: owner.supportsAutomation } + : owner, + ); + return [current.clients.get(clientId), { ...current, clients, owners }] as const; }); if (previous) yield* disconnect(clientId, previous.queue); return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); }); - const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( + const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( "PreviewAutomationBroker.reportOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { @@ -187,17 +193,25 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); - const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( + const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( "PreviewAutomationBroker.clearOwner", - )(function* (clientId) { + )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { + const currentOwner = current.owners.get(owner.clientId); + if ( + !currentOwner || + currentOwner.environmentId !== owner.environmentId || + currentOwner.threadId !== owner.threadId + ) { + return current; + } const owners = new Map(current.owners); - owners.delete(clientId); + owners.delete(owner.clientId); return { ...current, owners }; }); }); - const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( + const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( "PreviewAutomationBroker.respond", )(function* (response) { const pending = yield* SynchronizedRef.modify(state, (current) => { @@ -223,7 +237,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( - input: Parameters[0], + input: Parameters[0], ): Effect.fn.Return { const current = yield* SynchronizedRef.get(state); const candidates = Array.from(current.owners.values()) @@ -234,8 +248,13 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { owner.supportsAutomation, ) .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); - const owner = candidates[0]; + const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); if (!owner) { + if (candidates.length > 0) { + return yield* new PreviewAutomationUnavailableError({ + message: "The browser host is not connected.", + }); + } return yield* new PreviewAutomationNoFocusedOwnerError({ message: "No desktop browser host is available for this thread.", }); @@ -246,22 +265,12 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { message: "The browser host is not connected.", }); } - if ( - input.operation !== "open" && - input.operation !== "status" && - !owner.tabId && - !input.tabId - ) { - return yield* new PreviewAutomationTabNotFoundError({ - message: "The browser host does not have an active tab.", - }); - } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); const requestId = yield* SynchronizedRef.modify(state, (next) => { const requestId = `preview-${next.requestSequence}`; const pending = new Map(next.pending); - pending.set(requestId, { clientId: owner.clientId, deferred }); + pending.set(requestId, { queue: connection.queue, deferred }); return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; }); const removePending = SynchronizedRef.update(state, (next) => { @@ -302,8 +311,3 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); export const layer = Layer.effect(PreviewAutomationBroker, make); - -/** Exposed for tests. */ -export const __testing = { - make, -}; diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts new file mode 100644 index 00000000000..300a50fe330 --- /dev/null +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -0,0 +1,23 @@ +import type { TraceRecord, TraceSink } from "@t3tools/shared/observability"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class BrowserTraceCollector extends Context.Service< + BrowserTraceCollector, + { + readonly record: (records: ReadonlyArray) => Effect.Effect; + } +>()("t3/observability/BrowserTraceCollector") {} + +export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => + BrowserTraceCollector.of({ + record: (records) => + Effect.sync(() => { + for (const record of records) { + sink.push(record); + } + }), + }); + +export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink)); diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 95263866d80..11463cc1d85 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -4,17 +4,19 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Tracer from "effect/Tracer"; -import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; +import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; +import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; -import { ServerConfig } from "../../config.ts"; +import * as ServerConfig from "../../config.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; -import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; +import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; const otlpSerializationLayer = OtlpSerialization.layerJson; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const traceReferencesLayer = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), @@ -56,14 +58,7 @@ export const ObservabilityLive = Layer.unwrap( return Layer.mergeAll( Layer.succeed(Tracer.Tracer, tracer), - Layer.succeed(BrowserTraceCollector, { - record: (records) => - Effect.sync(() => { - for (const record of records) { - sink.push(record); - } - }), - }), + BrowserTraceCollector.layer(sink), ); }), ).pipe(Layer.provideMerge(otlpSerializationLayer)); diff --git a/apps/server/src/observability/Services/BrowserTraceCollector.ts b/apps/server/src/observability/Services/BrowserTraceCollector.ts deleted file mode 100644 index b704804c963..00000000000 --- a/apps/server/src/observability/Services/BrowserTraceCollector.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TraceRecord } from "@t3tools/shared/observability"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface BrowserTraceCollectorShape { - readonly record: (records: ReadonlyArray) => Effect.Effect; -} - -export class BrowserTraceCollector extends Context.Service< - BrowserTraceCollector, - BrowserTraceCollectorShape ->()("t3/observability/Services/BrowserTraceCollector") {} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5e36f9f4bab..707c87c43c9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import { ProviderDriverKind, @@ -30,12 +30,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; -import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -57,7 +56,7 @@ import { import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -199,7 +198,7 @@ async function waitForEvent( } function runGit(cwd: string, args: ReadonlyArray) { - return execFileSync("git", args, { + return NodeChildProcess.execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", @@ -207,11 +206,11 @@ function runGit(cwd: string, args: ReadonlyArray) { } function createGitRepository() { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-checkpoint-handler-")); + const cwd = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-checkpoint-handler-")); runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); - fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v1\n", "utf8"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); return cwd; @@ -247,7 +246,10 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) describe("CheckpointReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery, + | OrchestrationEngineService + | CheckpointReactor + | CheckpointStore.CheckpointStore + | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -265,7 +267,7 @@ describe("CheckpointReactor", () => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); + NodeFS.rmSync(dir, { recursive: true, force: true }); } } }); @@ -292,11 +294,11 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); @@ -328,14 +330,14 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(vcsStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer), ), ), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -345,7 +347,9 @@ describe("CheckpointReactor", () => { const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); - const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + const checkpointStore = await runtime.runPromise( + Effect.service(CheckpointStore.CheckpointStore), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -391,14 +395,14 @@ describe("CheckpointReactor", () => { checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), }), ); - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v2\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), }), ); - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "v3\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, @@ -452,7 +456,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-1"), @@ -550,7 +554,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", @@ -624,7 +628,7 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-claude-1"), @@ -757,7 +761,7 @@ describe("CheckpointReactor", () => { }), ); - fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), @@ -825,8 +829,8 @@ describe("CheckpointReactor", () => { }); it("continues processing runtime events after a single checkpoint runtime failure", async () => { - const nonRepositorySessionCwd = fs.mkdtempSync( - path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), + const nonRepositorySessionCwd = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-checkpoint-runtime-non-repo-"), ); tempDirs.push(nonRepositorySessionCwd); @@ -959,7 +963,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.make("thread-1"), numTurns: 1, }); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); expect( gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), ).toBe(false); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 48ff133f56d..3ba244ddf2c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -24,7 +24,7 @@ import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; -import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -81,7 +81,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; - const checkpointStore = yield* CheckpointStore; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 56876ec148e..b2ef0fed0f9 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/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 5a997de3669..0999000ed4f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -24,7 +24,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -2535,7 +2535,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..9a136b06872 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), ), @@ -1441,7 +1440,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 e629d1604b3..e36db35b107 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, @@ -262,7 +262,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 77f9a2ed904..ce464565dc5 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 64955590235..001ba388949 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 4bbf5ca2149..7d8a24069a3 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor, @@ -39,7 +39,7 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; + const terminalManager = yield* TerminalManager.TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 95d29e3d6d2..bed166eba45 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -11,14 +11,14 @@ import { import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( diff --git a/apps/server/src/pathExpansion.test.ts b/apps/server/src/pathExpansion.test.ts index a6f004d4e6f..cc7c85786da 100644 --- a/apps/server/src/pathExpansion.test.ts +++ b/apps/server/src/pathExpansion.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { homedir } from "node:os"; -import { join } from "node:path"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { expandHomePath } from "./pathExpansion.ts"; @@ -17,15 +17,15 @@ describe("expandHomePath", () => { }); it("expands a lone tilde to the home directory", () => { - expect(expandHomePath("~")).toBe(homedir()); + expect(expandHomePath("~")).toBe(NodeOS.homedir()); }); it("expands ~/ to a subpath of the home directory", () => { - expect(expandHomePath("~/.codex-work")).toBe(join(homedir(), ".codex-work")); + expect(expandHomePath("~/.codex-work")).toBe(NodePath.join(NodeOS.homedir(), ".codex-work")); }); it("expands a Windows-style ~\\ prefix", () => { - expect(expandHomePath("~\\.codex")).toBe(join(homedir(), ".codex")); + expect(expandHomePath("~\\.codex")).toBe(NodePath.join(NodeOS.homedir(), ".codex")); }); it("does not expand ~user paths", () => { diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts index 170d83c54d0..bacdaece0b1 100644 --- a/apps/server/src/pathExpansion.ts +++ b/apps/server/src/pathExpansion.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { homedir } from "node:os"; -import { join } from "node:path"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; /** * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the @@ -16,9 +16,9 @@ import { join } from "node:path"; */ export function expandHomePath(value: string): string { if (!value) return value; - if (value === "~") return homedir(); + if (value === "~") return NodeOS.homedir(); if (value.startsWith("~/") || value.startsWith("~\\")) { - return join(homedir(), value.slice(2)); + return NodePath.join(NodeOS.homedir(), value.slice(2)); } return value; } diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts similarity index 58% rename from apps/server/src/persistence/Layers/AuthPairingLinks.ts rename to apps/server/src/persistence/AuthPairingLinks.ts index 9d2760d1449..c29b023d1d8 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -1,33 +1,100 @@ -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 { AuthEnvironmentScopes } from "@t3tools/contracts"; import { - toPersistenceDecodeError, - toPersistenceSqlError, type AuthPairingLinkRepositoryError, -} from "../Errors.ts"; -import { - AuthPairingLinkRecord, + PersistenceDecodeError, + 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; + +export class AuthPairingLinkRepository extends Context.Service< AuthPairingLinkRepository, - type AuthPairingLinkRepositoryShape, - ConsumeAuthPairingLinkInput, - CreateAuthPairingLinkInput, - GetAuthPairingLinkByCredentialInput, - ListActiveAuthPairingLinksInput, - RevokeAuthPairingLinkInput, -} from "../Services/AuthPairingLinks.ts"; + { + 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) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } -const makeAuthPairingLinkRepository = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const createPairingLinkRow = SqlSchema.void({ @@ -154,7 +221,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { `, }); - const create: AuthPairingLinkRepositoryShape["create"] = (input) => + const create: AuthPairingLinkRepository["Service"]["create"] = (input) => createPairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -164,7 +231,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => + const consumeAvailable: AuthPairingLinkRepository["Service"]["consumeAvailable"] = (input) => consumeAvailablePairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -174,7 +241,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => + const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => listActivePairingLinkRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -184,7 +251,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => + const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) => revokePairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -195,7 +262,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); - const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => + const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) => getPairingLinkRowByCredential(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -211,10 +278,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { listActive, revoke, getByCredential, - } satisfies AuthPairingLinkRepositoryShape; + } satisfies AuthPairingLinkRepository["Service"]; }); -export const AuthPairingLinkRepositoryLive = Layer.effect( - AuthPairingLinkRepository, - makeAuthPairingLinkRepository, -); +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 64% rename from apps/server/src/persistence/Layers/AuthSessions.ts rename to apps/server/src/persistence/AuthSessions.ts index ab84e3fa041..17f76042d0a 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -1,27 +1,109 @@ -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, + 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, @@ -64,11 +146,11 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } -const makeAuthSessionRepository = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const createSessionRow = SqlSchema.void({ @@ -197,7 +279,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { `, }); - const create: AuthSessionRepositoryShape["create"] = (input) => + const create: AuthSessionRepository["Service"]["create"] = (input) => createSessionRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -207,7 +289,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { ), ); - const getById: AuthSessionRepositoryShape["getById"] = (input) => + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -223,7 +305,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { ), ); - const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + const listActive: AuthSessionRepository["Service"]["listActive"] = (input) => listActiveSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -234,7 +316,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), ); - const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + const revoke: AuthSessionRepository["Service"]["revoke"] = (input) => revokeSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -245,7 +327,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); - const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) => revokeOtherSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -256,7 +338,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { 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( @@ -273,10 +355,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, - } satisfies AuthSessionRepositoryShape; + } satisfies AuthSessionRepository["Service"]; }); -export const AuthSessionRepositoryLive = Layer.effect( - AuthSessionRepository, - makeAuthSessionRepository, -); +export const layer = Layer.effect(AuthSessionRepository, make); diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts new file mode 100644 index 00000000000..680a362e20a --- /dev/null +++ b/apps/server/src/persistence/Errors.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; + +const decodeRuntimePayload = Schema.decodeUnknownEffect( + Schema.Struct({ + runtimePayload: Schema.Struct({ + attempt: Schema.Number, + }), + }), +); + +it("keeps SQL operation context without a tautological detail", () => { + const cause = new Error("database unavailable"); + const error = new PersistenceSqlError({ + operation: "AuthSessionRepository.list:query", + cause, + }); + + assert.equal(error.operation, "AuthSessionRepository.list:query"); + assert.equal(error.detail, undefined); + assert.equal(error.cause, cause); + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); +}); + +it.effect("maps schema errors without copying rejected payloads into diagnostics", () => + Effect.gen(function* () { + const rejectedPayload = "runtime-payload-secret-sentinel"; + const cause = yield* Effect.flip( + decodeRuntimePayload({ + runtimePayload: { + attempt: rejectedPayload, + }, + }), + ); + const error = PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + ); + + assert.equal(error.operation, "ProviderSessionRuntimeRepository.list:decodeRows"); + assert.equal(error.cause, cause); + assert.notInclude(error.issue, rejectedPayload); + assert.notInclude(error.message, rejectedPayload); + assert.include(error.issue, "InvalidType"); + }), +); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189..e7d081c8f72 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,6 +1,20 @@ 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 // =============================== @@ -9,12 +23,14 @@ export class PersistenceSqlError extends Schema.TaggedErrorClass new PersistenceSqlError({ @@ -42,22 +67,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/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53..52e4f8f7408 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,208 +1,2 @@ -import { ThreadId } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type ProviderSessionRuntimeRepositoryError, -} from "../Errors.ts"; -import { - ProviderSessionRuntime, - ProviderSessionRuntimeRepository, - type ProviderSessionRuntimeRepositoryShape, -} from "../Services/ProviderSessionRuntime.ts"; - -const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( - Struct.assign({ - resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - }), -); - -const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); - -const GetRuntimeRequestSchema = Schema.Struct({ - threadId: ThreadId, -}); - -const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): ProviderSessionRuntimeRepositoryError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProviderSessionRuntimeRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertRuntimeRow = SqlSchema.void({ - Request: ProviderSessionRuntimeDbRowSchema, - execute: (runtime) => - sql` - INSERT INTO provider_session_runtime ( - thread_id, - provider_name, - provider_instance_id, - adapter_key, - runtime_mode, - status, - last_seen_at, - resume_cursor_json, - runtime_payload_json - ) - VALUES ( - ${runtime.threadId}, - ${runtime.providerName}, - ${runtime.providerInstanceId}, - ${runtime.adapterKey}, - ${runtime.runtimeMode}, - ${runtime.status}, - ${runtime.lastSeenAt}, - ${runtime.resumeCursor}, - ${runtime.runtimePayload} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - provider_name = excluded.provider_name, - provider_instance_id = excluded.provider_instance_id, - adapter_key = excluded.adapter_key, - runtime_mode = excluded.runtime_mode, - status = excluded.status, - last_seen_at = excluded.last_seen_at, - resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json - `, - }); - - const getRuntimeRowByThreadId = SqlSchema.findOneOption({ - Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, - execute: () => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - ORDER BY last_seen_at ASC, thread_id ASC - `, - }); - - const deleteRuntimeByThreadId = SqlSchema.void({ - Request: DeleteRuntimeRequestSchema, - execute: ({ threadId }) => - sql` - DELETE FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.upsert:query", - "ProviderSessionRuntimeRepository.upsert:encodeRequest", - ), - ), - ); - - const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => - getRuntimeRowByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:query", - "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", - ), - ), - Effect.flatMap((runtimeRowOption) => - Option.match(runtimeRowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", - ), - ), - Effect.map((runtime) => Option.some(runtime)), - ), - }), - ), - ); - - const list: ProviderSessionRuntimeRepositoryShape["list"] = () => - listRuntimeRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.list:query", - "ProviderSessionRuntimeRepository.list:decodeRows", - ), - ), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), - ), - ), - { concurrency: "unbounded" }, - ), - ), - ); - - const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => - deleteRuntimeByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByThreadId, - list, - deleteByThreadId, - } satisfies ProviderSessionRuntimeRepositoryShape; -}); - -export const ProviderSessionRuntimeRepositoryLive = Layer.effect( - ProviderSessionRuntimeRepository, - makeProviderSessionRuntimeRepository, -); +/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ +export { layer as ProviderSessionRuntimeRepositoryLive } from "../ProviderSessionRuntime.ts"; diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 6b91b5bd07b..f3d03e1c695 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"; @@ -29,11 +29,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; @@ -74,7 +69,7 @@ const checkNodeSqliteCompat = () => { const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, - openDatabase: () => DatabaseSync, + openDatabase: () => NodeSqlite.DatabaseSync, ): Effect.fn.Return { yield* checkNodeSqliteCompat(); @@ -91,8 +86,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( Effect.sync(() => db.close()), ); - 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,7 +113,11 @@ 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 { @@ -225,7 +224,7 @@ const make = ( makeWithDatabase( options, () => - new DatabaseSync(options.filename, { + new NodeSqlite.DatabaseSync(options.filename, { readOnly: options.readonly ?? false, allowExtension: options.allowExtension ?? false, }), @@ -241,7 +240,7 @@ const makeMemory = ( readonly: false, }, () => { - const database = new DatabaseSync(":memory:", { + const database = new NodeSqlite.DatabaseSync(":memory:", { allowExtension: config.allowExtension ?? false, }); return database; @@ -251,25 +250,12 @@ 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.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)); + 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)); + Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts new file mode 100644 index 00000000000..af48efdb50e --- /dev/null +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -0,0 +1,296 @@ +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, + 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 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) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, 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: 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: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => + upsertRuntimeRow(runtime).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.upsert:query", + "ProviderSessionRuntimeRepository.upsert:encodeRequest", + ), + ), + ); + + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["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((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + cause, + ), + ), + 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) => + decodeRuntime(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:rowToRuntime", + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ), + ), + ); + + const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteRuntimeByThreadId(input).pipe( + Effect.mapError( + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + cause, + }), + ), + ); + + return { + upsert, + getByThreadId, + list, + deleteByThreadId, + } satisfies ProviderSessionRuntimeRepository["Service"]; +}); + +export const layer = Layer.effect(ProviderSessionRuntimeRepository, make); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts deleted file mode 100644 index c8745982d29..00000000000 --- a/apps/server/src/persistence/Services/AuthPairingLinks.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; - -import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; - -export const AuthPairingLinkRecord = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: Schema.fromJsonString(AuthEnvironmentScopes), - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; - -export const CreateAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: AuthEnvironmentScopes, - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; - -export const ConsumeAuthPairingLinkInput = Schema.Struct({ - credential: Schema.String, - proofKeyThumbprint: Schema.NullOr(Schema.String), - consumedAt: Schema.DateTimeUtcFromString, - now: Schema.DateTimeUtcFromString, -}); -export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; - -export const ListActiveAuthPairingLinksInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; - -export const RevokeAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; - -export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ - credential: Schema.String, -}); -export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; - -export interface AuthPairingLinkRepositoryShape { - readonly create: ( - input: CreateAuthPairingLinkInput, - ) => Effect.Effect; - readonly consumeAvailable: ( - input: ConsumeAuthPairingLinkInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly listActive: ( - input: ListActiveAuthPairingLinksInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly revoke: ( - input: RevokeAuthPairingLinkInput, - ) => Effect.Effect; - readonly getByCredential: ( - input: GetAuthPairingLinkByCredentialInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; -} - -export class AuthPairingLinkRepository extends Context.Service< - AuthPairingLinkRepository, - AuthPairingLinkRepositoryShape ->()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts deleted file mode 100644 index c08956bdd71..00000000000 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AuthClientMetadataDeviceType, - AuthEnvironmentScopes, - AuthSessionId, - ServerAuthSessionMethod, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { AuthSessionRepositoryError } from "../Errors.ts"; - -export const AuthSessionClientMetadataRecord = Schema.Struct({ - label: Schema.NullOr(Schema.String), - ipAddress: Schema.NullOr(Schema.String), - userAgent: Schema.NullOr(Schema.String), - deviceType: AuthClientMetadataDeviceType, - os: Schema.NullOr(Schema.String), - browser: Schema.NullOr(Schema.String), -}); -export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; - -export const AuthSessionRecord = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthSessionRecord = typeof AuthSessionRecord.Type; - -export const CreateAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; - -export const GetAuthSessionByIdInput = Schema.Struct({ - sessionId: AuthSessionId, -}); -export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; - -export const ListActiveAuthSessionsInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; - -export const RevokeAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; - -export const RevokeOtherAuthSessionsInput = Schema.Struct({ - currentSessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; - -export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ - sessionId: AuthSessionId, - lastConnectedAt: Schema.DateTimeUtcFromString, -}); -export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; - -export interface AuthSessionRepositoryShape { - readonly create: ( - input: CreateAuthSessionInput, - ) => Effect.Effect; - readonly getById: ( - input: GetAuthSessionByIdInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly listActive: ( - input: ListActiveAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly revoke: ( - input: RevokeAuthSessionInput, - ) => Effect.Effect; - readonly revokeAllExcept: ( - input: RevokeOtherAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly setLastConnectedAt: ( - input: SetAuthSessionLastConnectedAtInput, - ) => Effect.Effect; -} - -export class AuthSessionRepository extends Context.Service< - AuthSessionRepository, - AuthSessionRepositoryShape ->()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts deleted file mode 100644 index 125f4fa5bbf..00000000000 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. - * - * Owns persistence operations for provider runtime metadata and resume cursors. - * - * @module ProviderSessionRuntimeRepository - */ -import { - IsoDateTime, - ProviderInstanceId, - ProviderSessionRuntimeStatus, - RuntimeMode, - ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; - -export const ProviderSessionRuntime = Schema.Struct({ - threadId: ThreadId, - providerName: Schema.String, - /** - * User-defined routing key for the configured provider instance that - * owns this session. Nullable only at the storage/migration boundary: - * rows persisted before the driver/instance split carry only - * `providerName`. Repository consumers must materialize a concrete - * instance id before routing. - */ - providerInstanceId: Schema.NullOr(ProviderInstanceId), - adapterKey: Schema.String, - runtimeMode: RuntimeMode, - status: ProviderSessionRuntimeStatus, - lastSeenAt: IsoDateTime, - resumeCursor: Schema.NullOr(Schema.Unknown), - runtimePayload: Schema.NullOr(Schema.Unknown), -}); -export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; - -export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; - -export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; - -/** - * ProviderSessionRuntimeRepositoryShape - Service API for provider runtime records. - */ -export interface ProviderSessionRuntimeRepositoryShape { - /** - * Insert or replace a provider runtime row. - * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. - */ - readonly upsert: ( - runtime: ProviderSessionRuntime, - ) => Effect.Effect; - - /** - * Read provider runtime state by canonical thread id. - */ - readonly getByThreadId: ( - input: GetProviderSessionRuntimeInput, - ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; - - /** - * List all provider runtime rows. - * - * Returned in ascending last-seen order. - */ - readonly list: () => Effect.Effect< - ReadonlyArray, - ProviderSessionRuntimeRepositoryError - >; - - /** - * Delete provider runtime state by canonical thread id. - */ - readonly deleteByThreadId: ( - input: DeleteProviderSessionRuntimeInput, - ) => Effect.Effect; -} - -/** - * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. - */ -export class ProviderSessionRuntimeRepository extends Context.Service< - ProviderSessionRuntimeRepository, - ProviderSessionRuntimeRepositoryShape ->()("t3/persistence/Services/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index a910e27470d..acdfe54301e 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -1,5 +1,6 @@ import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; +import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; import { Effect, PubSub } from "effect"; import { expect } from "vite-plus/test"; @@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip(manager.open({ threadId, url: " " })); expect(error._tag).toBe("PreviewInvalidUrlError"); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty"); + }), + ); + + it.effect("preserves URL parser failures as the invalid URL cause chain", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl })); + + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + const normalizationError = error.cause as PreviewUrlNormalizationError; + expect(normalizationError.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((normalizationError.cause as Error).message); + expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/); }), ); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8fa3a3668bf..fe3557c157f 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -24,37 +24,34 @@ import { type PreviewSessionSnapshot, } from "@t3tools/contracts"; import { + isPreviewUrlNormalizationError, newPreviewTabId, normalizePreviewUrl, - PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; -import { - Context, - DateTime, - Effect, - Layer, - PubSub, - type Scope, - Stream, - SynchronizedRef, -} from "effect"; - -export interface PreviewManagerShape { - readonly open: (input: PreviewOpenInput) => Effect.Effect; - readonly navigate: ( - input: PreviewNavigateInput, - ) => Effect.Effect; - readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; - readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; - readonly close: (input: PreviewCloseInput) => Effect.Effect; - readonly list: (input: PreviewListInput) => Effect.Effect; - readonly events: Stream.Stream; - readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; -} +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; -export class PreviewManager extends Context.Service()( - "t3/preview/Manager/PreviewManager", -) {} +export class PreviewManager extends Context.Service< + PreviewManager, + { + readonly open: (input: PreviewOpenInput) => Effect.Effect; + readonly navigate: ( + input: PreviewNavigateInput, + ) => Effect.Effect; + readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect; + readonly refresh: (input: PreviewRefreshInput) => Effect.Effect; + readonly close: (input: PreviewCloseInput) => Effect.Effect; + readonly list: (input: PreviewListInput) => Effect.Effect; + readonly events: Stream.Stream; + readonly subscribeEvents: Effect.Effect, never, Scope.Scope>; + } +>()("t3/preview/Manager/PreviewManager") {} interface PreviewSessionState { readonly threadId: string; @@ -85,16 +82,22 @@ const sessionsForThread = ( const normalizeUrl = (rawUrl: string): Effect.Effect => Effect.try({ try: () => normalizePreviewUrl(rawUrl), - catch: (cause) => - new PreviewInvalidUrlError({ - rawUrl, - detail: - cause instanceof PreviewUrlNormalizationError - ? cause.detail - : cause instanceof Error - ? cause.message - : String(cause), - }), + catch: (cause) => { + if (isPreviewUrlNormalizationError(cause)) { + return new PreviewInvalidUrlError({ + inputLength: cause.inputLength, + reason: cause.reason, + protocol: cause.protocol, + cause, + }); + } + + return new PreviewInvalidUrlError({ + inputLength: rawUrl.length, + reason: "unexpected", + cause, + }); + }, }); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); @@ -127,7 +130,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -const make = Effect.gen(function* PreviewManagerMake() { +export const make = Effect.gen(function* PreviewManagerMake() { const stateRef = yield* SynchronizedRef.make(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -184,38 +187,40 @@ const make = Effect.gen(function* PreviewManagerMake() { ); }; - const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) { - const tabId = newPreviewTabId(); - const updatedAt = yield* currentIsoTimestamp; - const snapshot = input.url - ? buildLoadingSnapshot({ + const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")( + function* (input) { + const tabId = newPreviewTabId(); + const updatedAt = yield* currentIsoTimestamp; + const snapshot = input.url + ? buildLoadingSnapshot({ + threadId: input.threadId, + tabId, + url: yield* normalizeUrl(input.url), + title: "", + updatedAt, + }) + : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); + yield* SynchronizedRef.update(stateRef, (state) => { + const sessions = new Map(state.sessions); + sessions.set(compositeKey(input.threadId, tabId), { threadId: input.threadId, tabId, - url: yield* normalizeUrl(input.url), - title: "", - updatedAt, - }) - : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); - yield* SynchronizedRef.update(stateRef, (state) => { - const sessions = new Map(state.sessions); - sessions.set(compositeKey(input.threadId, tabId), { + snapshot, + }); + return { sessions }; + }); + yield* PubSub.publish(eventsPubSub, { + type: "opened", threadId: input.threadId, tabId, + createdAt: snapshot.updatedAt, snapshot, }); - return { sessions }; - }); - yield* PubSub.publish(eventsPubSub, { - type: "opened", - threadId: input.threadId, - tabId, - createdAt: snapshot.updatedAt, - snapshot, - }); - return snapshot; - }); + return snapshot; + }, + ); - const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( + const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")( function* (input) { const url = yield* normalizeUrl(input.url); return yield* mutateExistingSession( @@ -250,7 +255,7 @@ const make = Effect.gen(function* PreviewManagerMake() { }, ); - const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn( + const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn( "PreviewManager.reportStatus", )(function* (input) { yield* mutateExistingSession( @@ -294,7 +299,7 @@ const make = Effect.gen(function* PreviewManagerMake() { ); }); - const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")( + const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( function* (input) { // Verify the session exists; the desktop bridge handles the actual reload // and will report progress back via `reportStatus`. No event emitted. @@ -304,50 +309,54 @@ const make = Effect.gen(function* PreviewManagerMake() { }, ); - const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) { - const createdAt = yield* currentIsoTimestamp; - const events = yield* SynchronizedRef.modify(stateRef, (state) => { - const eventsToEmit: PreviewEvent[] = []; - const sessions = new Map(state.sessions); - const targets = input.tabId - ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( - (entry): entry is PreviewSessionState => entry !== undefined, - ) - : sessionsForThread(state, input.threadId); - for (const target of targets) { - sessions.delete(compositeKey(target.threadId, target.tabId)); - eventsToEmit.push({ - type: "closed", - threadId: target.threadId, - tabId: target.tabId, - createdAt, + const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( + function* (input) { + const createdAt = yield* currentIsoTimestamp; + const events = yield* SynchronizedRef.modify(stateRef, (state) => { + const eventsToEmit: PreviewEvent[] = []; + const sessions = new Map(state.sessions); + const targets = input.tabId + ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( + (entry): entry is PreviewSessionState => entry !== undefined, + ) + : sessionsForThread(state, input.threadId); + for (const target of targets) { + sessions.delete(compositeKey(target.threadId, target.tabId)); + eventsToEmit.push({ + type: "closed", + threadId: target.threadId, + tabId: target.tabId, + createdAt, + }); + } + if (eventsToEmit.length === 0) { + return [eventsToEmit, state] as const; + } + return [eventsToEmit, { sessions }] as const; + }); + if (events.length > 0) { + yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { + discard: true, }); } - if (eventsToEmit.length === 0) { - return [eventsToEmit, state] as const; - } - return [eventsToEmit, { sessions }] as const; - }); - if (events.length > 0) { - yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { - discard: true, - }); - } - }); + }, + ); - const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) { - return yield* SynchronizedRef.get(stateRef).pipe( - Effect.map( - (state): PreviewListResult => ({ - sessions: sessionsForThread(state, input.threadId) - .map((s) => s.snapshot) - .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), - }), - ), - ); - }); + const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")( + function* (input) { + return yield* SynchronizedRef.get(stateRef).pipe( + Effect.map( + (state): PreviewListResult => ({ + sessions: sessionsForThread(state, input.threadId) + .map((s) => s.snapshot) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), + }), + ), + ); + }, + ); - return { + return PreviewManager.of({ open, navigate, reportStatus, @@ -356,7 +365,7 @@ const make = Effect.gen(function* PreviewManagerMake() { list, events, subscribeEvents: PubSub.subscribe(eventsPubSub), - } satisfies PreviewManagerShape; + }); }).pipe(Effect.withSpan("PreviewManager.make")); export const layer = Layer.effect(PreviewManager, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 481d28d782f..6c48f6d5c8b 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,4 +1,4 @@ -import * as net from "node:net"; +import * as NodeNet from "node:net"; import { it as effectIt } from "@effect/vitest"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; import { expect } from "vite-plus/test"; -import { ProcessRunner } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; -const TestProcessRunner = Layer.succeed(ProcessRunner, { +const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); const TestPortDiscoveryLive = PortScanner.layer.pipe( @@ -17,9 +17,9 @@ const TestPortDiscoveryLive = PortScanner.layer.pipe( ), ); -const openServer = (port: number): Effect.Effect => +const openServer = (port: number): Effect.Effect => Effect.callback((resume) => { - const server = net.createServer(); + const server = NodeNet.createServer(); server.once("error", () => { resume(Effect.succeed(null)); }); @@ -31,7 +31,7 @@ const openServer = (port: number): Effect.Effect => }); }); -const closeServer = (server: net.Server): Effect.Effect => +const closeServer = (server: NodeNet.Server): Effect.Effect => Effect.callback((resume) => { server.close(() => resume(Effect.void)); }); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 183d5d4f009..16ff0fed58f 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -15,30 +15,36 @@ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; -import { ProcessRunner } from "../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; -export interface PortDiscoveryShape { - readonly scan: () => Effect.Effect>; - readonly subscribe: ( - listener: (servers: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect; - readonly retain: Effect.Effect; - readonly registerTerminalProcesses: (input: { - readonly threadId: string; - readonly terminalId: string; - readonly processIds: ReadonlyArray; - }) => Effect.Effect; - readonly unregisterTerminal: (input: { - readonly threadId: string; - readonly terminalId: string; - }) => Effect.Effect; -} - -export class PortDiscovery extends Context.Service()( - "t3/preview/PortScanner/PortDiscovery", -) {} +export class PortDiscovery extends Context.Service< + PortDiscovery, + { + readonly scan: () => Effect.Effect>; + readonly subscribe: ( + listener: (servers: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect; + readonly retain: Effect.Effect; + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; + } +>()("t3/preview/PortScanner/PortDiscovery") {} export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, @@ -180,9 +186,9 @@ const serversEqual = ( return true; }; -const make = Effect.gen(function* PortDiscoveryMake() { +export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; - const processRunner = yield* ProcessRunner; + const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; const stateRef = yield* Ref.make({ lastSnapshot: [], @@ -296,14 +302,14 @@ const make = Effect.gen(function* PortDiscoveryMake() { } }); - const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () => + const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(acquireRetention(), () => Ref.update(stateRef, (state) => ({ ...state, retainCount: Math.max(0, state.retainCount - 1), })), ); - const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( + const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( Ref.update(stateRef, (state) => ({ @@ -319,29 +325,28 @@ const make = Effect.gen(function* PortDiscoveryMake() { ), ); - const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( - "PortDiscovery.registerTerminalProcesses", - )(function* (input) { - const owner = { - threadId: ThreadId.make(input.threadId), - terminalId: input.terminalId, - }; - const processIds = new Set( - input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), - ); - yield* Ref.update(stateRef, (state) => { - const terminalProcesses = new Map(state.terminalProcesses); - const key = terminalOwnerKey(owner); - if (processIds.size === 0) { - terminalProcesses.delete(key); - } else { - terminalProcesses.set(key, { owner, processIds }); - } - return { ...state, terminalProcesses }; + const registerTerminalProcesses: PortDiscovery["Service"]["registerTerminalProcesses"] = + Effect.fn("PortDiscovery.registerTerminalProcesses")(function* (input) { + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + yield* Ref.update(stateRef, (state) => { + const terminalProcesses = new Map(state.terminalProcesses); + const key = terminalOwnerKey(owner); + if (processIds.size === 0) { + terminalProcesses.delete(key); + } else { + terminalProcesses.set(key, { owner, processIds }); + } + return { ...state, terminalProcesses }; + }); }); - }); - const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( + const unregisterTerminal: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn( "PortDiscovery.unregisterTerminal", )(function* (input) { yield* Ref.update(stateRef, (state) => { @@ -351,13 +356,13 @@ const make = Effect.gen(function* PortDiscoveryMake() { }); }); - return { + return PortDiscovery.of({ scan: scanOnce, subscribe, retain, registerTerminalProcesses, unregisterTerminal, - } satisfies PortDiscoveryShape; + }); }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0a157e301c4..43ca40e9c7c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; +import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -54,7 +54,7 @@ const testLayer = (input: { ); return Layer.mergeAll( - ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { let spawned: ChildProcess.StandardCommand | undefined; let didUnref = false; return Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchBrowser("https://example.com/some path"); @@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher; + const launcher = yield* ExternalLauncher.ExternalLauncher; return yield* launcher.resolveAvailableEditors(); }).pipe( Effect.provide( @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { - const launcher = yield* ExternalLauncher; - const result = yield* launcher + const launcher = yield* ExternalLauncher.ExternalLauncher; + const error = yield* launcher .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) - .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + .pipe(Effect.flip); + assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); + assert.equal(error.editor, "missing-editor"); + assert.equal(error.message, "Unknown editor: missing-editor"); }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 0b40acef5c0..9c2f0e417d3 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -9,6 +9,11 @@ import { EDITORS, ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; @@ -22,15 +27,26 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; // ============================== // Definitions // ============================== -export { ExternalLauncherError }; +export { + ExternalLauncherError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherEditorSpawnError, + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + isExternalLauncherError, +} from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { + readonly editor: EditorId; + readonly target: string; readonly command: string; readonly args: ReadonlyArray; } @@ -282,30 +298,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit return yield* buildAvailableEditors(platform, env); }); -/** - * ExternalLauncherShape - Service API for browser and editor launch actions. - */ -export interface ExternalLauncherShape { - readonly resolveAvailableEditors: () => Effect.Effect>; - /** - * Launch a URL target in the default browser. - */ - readonly launchBrowser: (target: string) => Effect.Effect; - - /** - * Launch a workspace path in a selected editor integration. - * - * Launches the editor as a detached process so server startup is not blocked. - */ - readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; -} - /** * ExternalLauncher - Service tag for browser/editor launch operations. */ -export class ExternalLauncher extends Context.Service()( - "t3/process/externalLauncher", -) {} +export class ExternalLauncher extends Context.Service< + ExternalLauncher, + { + readonly resolveAvailableEditors: () => Effect.Effect>; + /** Launch a URL target in the default browser. */ + readonly launchBrowser: (target: string) => Effect.Effect; + /** + * Launch a workspace path in a selected editor integration. + * + * Launches the editor as a detached process so server startup is not blocked. + */ + readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; + } +>()("t3/process/externalLauncher") {} // ============================== // Implementations @@ -323,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }); const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { - return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor }); } if (editorDef.commands) { @@ -332,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( () => editorDef.commands[0], ); return { + editor: editorDef.id, + target: input.cwd, command, args: resolveEditorArgs(editorDef, input.cwd), }; } if (editorDef.id !== "file-manager") { - return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; + return { + editor: editorDef.id, + target: input.cwd, + command: fileManagerCommandForPlatform(platform), + args: [input.cwd], + }; }); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, - errorMessage: string, + onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make(launch.command, launch.args, launch.options); @@ -355,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( Effect.flatMap((handle) => handle.unref), Effect.asVoid, Effect.scoped, - Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })), + Effect.mapError(onError), ); }); @@ -363,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { const launch = yield* resolveBrowserLaunch(target); - return yield* launchAndUnref(launch, "Browser auto-open failed"); + return yield* launchAndUnref( + launch, + (cause) => + new ExternalLauncherBrowserSpawnError({ + target, + command: launch.command, + args: launch.args, + cause, + }), + ); }); const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( @@ -375,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu > { const env = yield* readCommandLookupEnv; if (!(yield* isCommandAvailable(launch.command, { env }))) { - return yield* new ExternalLauncherError({ - message: `Editor command not found: ${launch.command}`, + return yield* new ExternalLauncherCommandNotFoundError({ + editor: launch.editor, + command: launch.command, }); } @@ -393,11 +419,18 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu stderr: "ignore", }, }, - "failed to spawn detached process", + (cause) => + new ExternalLauncherEditorSpawnError({ + editor: launch.editor, + target: launch.target, + command: spawnCommand.command, + args: spawnCommand.args, + cause, + }), ); }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -410,7 +443,7 @@ const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - return { + return ExternalLauncher.of({ resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( @@ -424,7 +457,7 @@ const make = Effect.gen(function* () { ), ), ), - } satisfies ExternalLauncherShape; + }); }); export const layer = Layer.effect(ExternalLauncher, make); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index f914c667a1c..e264ba7849d 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; @@ -11,14 +12,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; -import { - isWindowsCommandNotFound, - ProcessOutputLimitError, - ProcessRunner, - ProcessTimeoutError, - layer as ProcessRunnerLive, - type ProcessRunInput, -} from "./processRunner.ts"; +import * as ProcessRunner from "./processRunner.ts"; type ChildProcessCommand = { readonly command: string; @@ -62,21 +56,24 @@ function makeHandle(input: { } function makeSpawner( - f: (command: ChildProcessCommand) => Effect.Effect, + f: ( + command: ChildProcessCommand, + ) => Effect.Effect, ) { return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); } const runWith = - (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) => - Effect.service(ProcessRunner).pipe( + (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => + (input: ProcessRunner.ProcessRunInput) => + Effect.service(ProcessRunner.ProcessRunner).pipe( Effect.flatMap((runner) => runner.run({ ...input, }), ), Effect.provide( - ProcessRunnerLive.pipe( + ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ), ), @@ -112,12 +109,12 @@ describe("runProcess", () => { return makeHandle({ stdout: "service ok" }); }), ); - const layer = ProcessRunnerLive.pipe( + const layer = ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ); return Effect.gen(function* () { - const runner = yield* ProcessRunner; + const runner = yield* ProcessRunner.ProcessRunner; const result = yield* runner.run({ command: "fake", args: ["--service"], @@ -165,6 +162,44 @@ describe("runProcess", () => { ); }); + it.effect("preserves resolved spawn context and cause", () => + Effect.gen(function* () { + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: "/actual/fake", + }); + const spawner = makeSpawner(() => Effect.fail(cause)); + + const error = yield* runWith(spawner)({ + command: "fake", + args: ["--flag", "secret-token-value"], + cwd: "/logical", + spawnCwd: "/actual", + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProcessSpawnError"); + if (error._tag !== "ProcessSpawnError") { + return expect.fail("Expected ProcessSpawnError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 2, + cwd: "/logical", + spawnCwd: "/actual", + resolvedCommand: "fake", + resolvedArgumentCount: 2, + shell: false, + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); + expect(error).not.toHaveProperty("args"); + expect(error).not.toHaveProperty("resolvedArgs"); + expect(error.message).not.toContain("secret-token-value"); + }), + ); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); @@ -175,7 +210,39 @@ describe("runProcess", () => { maxOutputBytes: 128, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error._tag).toBe("ProcessOutputLimitError"); + if (error._tag !== "ProcessOutputLimitError") { + return expect.fail("Expected ProcessOutputLimitError"); + } + expect(error).toMatchObject({ + stream: "stdout", + maxBytes: 128, + observedBytes: 2048, + }); + expect(error.message).toBe( + "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit", + ); + }), + ); + + it.effect("accepts output at the byte limit followed by an empty chunk", () => + Effect.gen(function* () { + const output = new TextEncoder().encode("exactly"); + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdout: Stream.make(output, new Uint8Array()), + }), + ), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["exact-limit"], + maxOutputBytes: output.byteLength, + }); + + expect(result.stdout).toBe("exactly"); }), ); @@ -200,7 +267,7 @@ describe("runProcess", () => { timeout: "2 seconds", }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); }), ); @@ -278,6 +345,8 @@ describe("runProcess", () => { const errorFiber = yield* runWith(spawner)({ command: "fake", args: ["sleep"], + cwd: "/logical", + spawnCwd: "/actual", timeout: "50 millis", }).pipe(Effect.flip, Effect.forkScoped); @@ -285,7 +354,18 @@ describe("runProcess", () => { yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); - expect(error).toBeInstanceOf(ProcessTimeoutError); + expect(error._tag).toBe("ProcessTimeoutError"); + if (error._tag !== "ProcessTimeoutError") { + return expect.fail("Expected ProcessTimeoutError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 1, + cwd: "/logical", + spawnCwd: "/actual", + timeoutMs: 50, + }); + expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); }), ); @@ -324,7 +404,7 @@ describe("runProcess", () => { describe("isWindowsCommandNotFound", () => { it.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { - const isCommandNotFound = yield* isWindowsCommandNotFound( + const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", ).pipe(Effect.provideService(HostProcessPlatform, "win32")); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 4cfb764c557..c1ee2b2cb0c 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -1,13 +1,14 @@ -import * as Data from "effect/Data"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { @@ -42,57 +43,106 @@ export interface ProcessRunOutput { readonly stderrTruncated: boolean; } -export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly cause: unknown; -}> {} +const ProcessInvocationFields = { + command: Schema.String, + argumentCount: Schema.Number, + cwd: Schema.optional(Schema.String), + spawnCwd: Schema.optional(Schema.String), +}; -export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{ +const formatProcessInvocation = (input: { readonly command: string; - readonly args: ReadonlyArray; readonly cwd?: string | undefined; - readonly cause: unknown; -}> {} + readonly spawnCwd?: string | undefined; +}): string => { + const executionCwd = input.spawnCwd ?? input.cwd; + return executionCwd === undefined + ? `'${input.command}'` + : `'${input.command}' in '${executionCwd}'`; +}; -export class ProcessOutputLimitError extends Data.TaggedError("ProcessOutputLimitError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -}> {} +export class ProcessSpawnError extends Schema.TaggedErrorClass()( + "ProcessSpawnError", + { + ...ProcessInvocationFields, + resolvedCommand: Schema.optional(Schema.String), + resolvedArgumentCount: Schema.optional(Schema.Number), + shell: Schema.optional(Schema.Boolean), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn process ${formatProcessInvocation(this)}`; + } +} -export class ProcessReadError extends Data.TaggedError("ProcessReadError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; -}> {} +export class ProcessStdinError extends Schema.TaggedErrorClass()( + "ProcessStdinError", + { + ...ProcessInvocationFields, + stdinBytes: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to write stdin for process ${formatProcessInvocation(this)}`; + } +} -export class ProcessTimeoutError extends Data.TaggedError("ProcessTimeoutError")<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string | undefined; - readonly timeoutMs: number; -}> {} +export class ProcessOutputLimitError extends Schema.TaggedErrorClass()( + "ProcessOutputLimitError", + { + ...ProcessInvocationFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: Schema.Number, + observedBytes: Schema.Number, + }, +) { + override get message(): string { + return `Process ${formatProcessInvocation(this)} ${this.stream} produced ${this.observedBytes} bytes, exceeding the ${this.maxBytes} byte limit`; + } +} -export type ProcessRunError = - | ProcessSpawnError - | ProcessStdinError - | ProcessOutputLimitError - | ProcessReadError - | ProcessTimeoutError; +export class ProcessReadError extends Schema.TaggedErrorClass()( + "ProcessReadError", + { + ...ProcessInvocationFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.stream} for process ${formatProcessInvocation(this)}`; + } +} -export interface ProcessRunnerShape { - readonly run: (input: ProcessRunInput) => Effect.Effect; +export class ProcessTimeoutError extends Schema.TaggedErrorClass()( + "ProcessTimeoutError", + { + ...ProcessInvocationFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Process ${formatProcessInvocation(this)} timed out after ${this.timeoutMs}ms`; + } } -export class ProcessRunner extends Context.Service()( - "t3/processRunner", -) {} +export const ProcessRunError = Schema.Union([ + ProcessSpawnError, + ProcessStdinError, + ProcessOutputLimitError, + ProcessReadError, + ProcessTimeoutError, +]); +export type ProcessRunError = typeof ProcessRunError.Type; + +export class ProcessRunner extends Context.Service< + ProcessRunner, + { + readonly run: (input: ProcessRunInput) => Effect.Effect; + } +>()("t3/processRunner") {} const DEFAULT_TIMEOUT = "60 seconds"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -123,6 +173,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; readonly streamName: "stdout" | "stderr"; readonly stream: Stream.Stream; readonly maxOutputBytes: number; @@ -134,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, cause, }), @@ -163,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { () => ({ chunks: [], bytes: 0 }), (state, chunk) => { const remainingBytes = input.maxOutputBytes - state.bytes; - if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) { + if (chunk.byteLength > remainingBytes) { return Effect.fail( new ProcessOutputLimitError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, maxBytes: input.maxOutputBytes, + observedBytes: state.bytes + chunk.byteLength, }), ); } @@ -219,8 +273,9 @@ function finalizeRunProcess( return Effect.fail( new ProcessTimeoutError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, timeoutMs: Duration.toMillis(timeout), }), ); @@ -260,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessSpawnError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + resolvedCommand: spawnCommand.command, + resolvedArgumentCount: spawnCommand.args.length, + shell: spawnCommand.shell, cause, }), ), ); + const stdin = input.stdin; const writeStdin = - input.stdin === undefined + stdin === undefined ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( Effect.mapError( (cause) => new ProcessStdinError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + stdinBytes: Buffer.byteLength(stdin), cause, }), ), @@ -288,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stdout", stream: child.stdout, maxOutputBytes, @@ -298,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stderr", stream: child.stderr, maxOutputBytes, @@ -314,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: "exitCode", cause, }), @@ -332,10 +397,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); -export const make = Effect.fn("makeProcessRunner")(function* () { +export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const run: ProcessRunnerShape["run"] = (input) => + const run: ProcessRunner["Service"]["run"] = (input) => finalizeRunProcess(runProcessCore(spawner, input), input); return ProcessRunner.of({ diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index 051a7d20de0..00000000000 --- 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 61cd043b43b..00000000000 --- 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/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts similarity index 82% rename from apps/server/src/project/Layers/ProjectFaviconResolver.test.ts rename to apps/server/src/project/ProjectFaviconResolver.test.ts index 5c0e5d95742..37bda11e6aa 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -5,12 +5,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; -import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(NodeServices.layer), ); @@ -39,7 +38,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { it.effect("prefers well-known favicon files", () => Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "favicon.svg", "favicon"); @@ -52,7 +51,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "index.html", ''); yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); @@ -66,7 +65,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { it.effect("returns null when no icon is present", () => Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cwd = yield* makeTempDir; const resolved = yield* resolver.resolvePath(cwd); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts similarity index 75% rename from apps/server/src/project/Layers/ProjectFaviconResolver.ts rename to apps/server/src/project/ProjectFaviconResolver.ts index a994d1a7e8c..4c685a20f88 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -1,13 +1,18 @@ +/** + * 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 Path from "effect/Path"; -import { - ProjectFaviconResolver, - type ProjectFaviconResolverShape, -} from "../Services/ProjectFaviconResolver.ts"; -import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ @@ -51,6 +56,19 @@ const LINK_ICON_HTML_RE = const LINK_ICON_OBJ_RE = /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +/** 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]; @@ -59,12 +77,12 @@ function extractIconHref(source: string): string | null { return null; } -export const makeProjectFaviconResolver = Effect.gen(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const resolveIconHref = (href: string): string[] => { + const resolveIconHref = (href: string): ReadonlyArray => { const clean = href.replace(/^\//, ""); return [path.join("public", clean), clean]; }; @@ -93,9 +111,9 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { + )(function* (cwd) { const projectCwd = yield* workspacePaths .normalizeWorkspaceRoot(cwd) .pipe(Effect.orElseSucceed(() => null)); @@ -138,12 +156,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { return null; }); - return { - resolvePath, - } satisfies ProjectFaviconResolverShape; + return ProjectFaviconResolver.of({ resolvePath }); }); -export const ProjectFaviconResolverLive = Layer.effect( - ProjectFaviconResolver, - makeProjectFaviconResolver, -); +export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 00000000000..e8d771b74df --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,199 @@ +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.TerminalCwdError({ + cwd: "/repo/worktrees/a", + reason: "statFailed", + cause: rootCause, + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const error = yield* runner + .runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isProjectSetupScriptOperationError(error)).toBe(true); + if (isProjectSetupScriptOperationError(error)) { + expect(error.operation).toBe("openTerminal"); + expect(error.threadId).toBe("thread-1"); + expect(error.projectId).toBe("project-1"); + expect(error.worktreePath).toBe("/repo/worktrees/a"); + expect(error.cause).toBe(terminalError); + expect(terminalError.cause).toBe(rootCause); + } + }).pipe( + Effect.provide( + testLayer(project, { + open: () => Effect.fail(terminalError), + write: () => Effect.die("unexpected write"), + }), + ), + ); + }); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts new file mode 100644 index 00000000000..41bf0fabf48 --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -0,0 +1,188 @@ +import { ProjectId } from "@t3tools/contracts"; +import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; + +export interface ProjectSetupScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectSetupScriptRunnerResultStarted { + readonly status: "started"; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + readonly cwd: string; +} + +export type ProjectSetupScriptRunnerResult = + | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultStarted; + +export interface ProjectSetupScriptRunnerInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; +} + +export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass()( + "ProjectSetupScriptOperationError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorClass()( + "ProjectSetupScriptProjectNotFoundError", + { + threadId: Schema.String, + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + }, +) { + override get message(): string { + return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`; + } +} + +export const ProjectSetupScriptRunnerError = Schema.Union([ + ProjectSetupScriptOperationError, + ProjectSetupScriptProjectNotFoundError, +]); +export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; + +export class ProjectSetupScriptRunner extends Context.Service< + ProjectSetupScriptRunner, + { + readonly runForThread: ( + input: ProjectSetupScriptRunnerInput, + ) => Effect.Effect; + } +>()("t3/project/ProjectSetupScriptRunner") {} + +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const terminalManager = yield* TerminalManager.TerminalManager; + + const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( + "ProjectSetupScriptRunner.runForThread", + )(function* (input) { + const errorContext = { + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + const projectById = input.projectId + ? yield* projectionSnapshotQuery.getProjectShellById(ProjectId.make(input.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null; + const project = + projectById ?? + (input.projectCwd + ? yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(input.projectCwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) + : null); + + if (!project) { + return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); + } + + const script = setupProjectScript(project.scripts); + if (!script) { + return { + status: "no-script", + } as const; + } + + const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const cwd = input.worktreePath; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + }); + + yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + yield* terminalManager + .write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); + + return { + status: "started", + scriptId: script.id, + scriptName: script.name, + terminalId, + cwd, + } as const; + }); + + return ProjectSetupScriptRunner.of({ runForThread }); +}); + +export const layer = Layer.effect(ProjectSetupScriptRunner, make); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts similarity index 88% rename from apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts rename to apps/server/src/project/RepositoryIdentityResolver.test.ts index 1c985cd8592..a997459e63d 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -7,12 +7,8 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; -import * as ProcessRunner from "../../processRunner.ts"; -import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; -import { - makeRepositoryIdentityResolver, - RepositoryIdentityResolverLive, -} from "./RepositoryIdentityResolver.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); @@ -31,8 +27,8 @@ const makeRepositoryIdentityResolverTestLayer = (options: { readonly negativeCacheTtl?: Duration.Input; }) => Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver({ + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make({ cacheCapacity: 16, ...options, }), @@ -49,7 +45,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); @@ -62,7 +58,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns the git top-level root path when resolving from a nested workspace", () => @@ -78,7 +74,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(repoRoot, ["init"]); yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); @@ -89,7 +85,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( normalizeResolvedPath(resolvedRepoRoot), ); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns null for non-git folders and repos without remotes", () => @@ -104,13 +100,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(gitDir, ["init"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const nonGitIdentity = yield* resolver.resolve(nonGitDir); const noRemoteIdentity = yield* resolver.resolve(gitDir); expect(nonGitIdentity).toBeNull(); expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("prefers upstream over origin when both remotes are configured", () => @@ -124,14 +120,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); expect(identity?.locator.remoteName).toBe("upstream"); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(identity?.displayName).toBe("t3tools/t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => @@ -144,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); @@ -152,7 +148,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("t3tools/platform/t3code"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect( @@ -166,7 +162,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).toBeNull(); @@ -206,7 +202,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const resolver = yield* RepositoryIdentityResolver; + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).not.toBeNull(); expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts similarity index 53% rename from apps/server/src/project/Layers/RepositoryIdentityResolver.ts rename to apps/server/src/project/RepositoryIdentityResolver.ts index d4ae073b953..50608e7704c 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,19 +1,33 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; -import * as ProcessRunner from "../../processRunner.ts"; -import { +import * as ProcessRunner from "../processRunner.ts"; + +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +export interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, - type RepositoryIdentityResolverShape, -} from "../Services/RepositoryIdentityResolver.ts"; + { + readonly resolve: (cwd: string) => Effect.Effect; + } +>()("t3/project/RepositoryIdentityResolver") {} function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); @@ -73,101 +87,88 @@ function buildRepositoryIdentity(input: { }; } -const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); +const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( + function* (cwd: string) { + const processRunner = yield* ProcessRunner.ProcessRunner; + let cacheKey = cwd; -interface RepositoryIdentityResolverOptions { - readonly cacheCapacity?: number; - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -} + // git is a real executable on every platform — no cmd.exe shell mode, which + // would split paths containing spaces during cmd's re-tokenization. + const topLevelResult = yield* processRunner + .run({ + command: "git", + args: ["-C", cwd, "rev-parse", "--show-toplevel"], + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { + return cacheKey; + } -const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( - cwd: string, -) { - const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; + const candidate = topLevelResult.value.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + + return cacheKey; + }, +); - // git is a real executable on every platform — no cmd.exe shell mode, which - // would split paths containing spaces during cmd's re-tokenization. - const topLevelResult = yield* processRunner +const resolveRepositoryIdentityFromCacheKey = Effect.fn( + "RepositoryIdentityResolver.resolveFromCacheKey", +)(function* ( + cacheKey: string, +): Effect.fn.Return { + const processRunner = yield* ProcessRunner.ProcessRunner; + const remoteResult = yield* processRunner .run({ command: "git", - args: ["-C", cwd, "rev-parse", "--show-toplevel"], + args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; - } - - const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; + if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { + return null; } - return cacheKey; + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; }); -const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( - function* ( - cacheKey: string, - ): Effect.fn.Return { - const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner - .run({ - command: "git", - args: ["-C", cacheKey, "remote", "-v"], - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); - if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { - return null; - } - - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; - }, -); +export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( + options: RepositoryIdentityResolverOptions = {}, +) { + const processRunner = yield* ProcessRunner.ProcessRunner; -export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( - function* (options: RepositoryIdentityResolverOptions = {}) { - const processRunner = yield* ProcessRunner.ProcessRunner; + const repositoryIdentityCache = yield* Cache.makeWith( + (cacheKey) => + resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); - const repositoryIdentityCache = yield* Cache.makeWith( - (cacheKey) => - resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ), - { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }); - const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); - return yield* Cache.get(repositoryIdentityCache, cacheKey); - }); + return RepositoryIdentityResolver.of({ resolve }); +}); - return { - resolve, - } satisfies RepositoryIdentityResolverShape; - }, +export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( + Layer.provide(ProcessRunner.layer), ); - -export const RepositoryIdentityResolverLive = Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver(), -).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts deleted file mode 100644 index ad1b466e2c7..00000000000 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * ProjectFaviconResolver - Effect service contract for project icon discovery. - * - * Resolves a representative favicon or app icon file for a workspace by - * checking common file locations and project source metadata. - * - * @module ProjectFaviconResolver - */ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -/** - * ProjectFaviconResolverShape - Service API for project favicon lookup. - */ -export interface ProjectFaviconResolverShape { - /** - * Resolve a favicon or icon file path for the provided workspace root. - * - * Returns `null` when no candidate icon file can be found. - */ - readonly resolvePath: (cwd: string) => Effect.Effect; -} - -/** - * ProjectFaviconResolver - Service tag for project favicon resolution. - */ -export class ProjectFaviconResolver extends Context.Service< - ProjectFaviconResolver, - ProjectFaviconResolverShape ->()("t3/project/Services/ProjectFaviconResolver") {} diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts deleted file mode 100644 index 17168eda7f1..00000000000 --- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import type * as Effect from "effect/Effect"; - -export interface ProjectSetupScriptRunnerResultNoScript { - readonly status: "no-script"; -} - -export interface ProjectSetupScriptRunnerResultStarted { - readonly status: "started"; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - readonly cwd: string; -} - -export type ProjectSetupScriptRunnerResult = - | ProjectSetupScriptRunnerResultNoScript - | ProjectSetupScriptRunnerResultStarted; - -export interface ProjectSetupScriptRunnerInput { - readonly threadId: string; - readonly projectId?: string; - readonly projectCwd?: string; - readonly worktreePath: string; - readonly preferredTerminalId?: string; -} - -export class ProjectSetupScriptRunnerError extends Data.TaggedError( - "ProjectSetupScriptRunnerError", -)<{ - readonly message: string; -}> {} - -export interface ProjectSetupScriptRunnerShape { - readonly runForThread: ( - input: ProjectSetupScriptRunnerInput, - ) => Effect.Effect; -} - -export class ProjectSetupScriptRunner extends Context.Service< - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerShape ->()("t3/project/Services/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts deleted file mode 100644 index ef0b128c6f7..00000000000 --- a/apps/server/src/project/Services/RepositoryIdentityResolver.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RepositoryIdentity } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export interface RepositoryIdentityResolverShape { - readonly resolve: (cwd: string) => Effect.Effect; -} - -export class RepositoryIdentityResolver extends Context.Service< - RepositoryIdentityResolver, - RepositoryIdentityResolverShape ->()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f813..f2b04b3a282 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -20,12 +20,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { @@ -48,6 +48,11 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -83,7 +88,8 @@ export type ClaudeDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -114,6 +120,7 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -163,16 +170,19 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings).pipe(Effect.map(stampIdentity)), + makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..ffcc94ca77d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -28,12 +28,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; @@ -47,6 +47,11 @@ import { makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -75,7 +80,8 @@ export type CodexDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; /** * Stamp instance identity onto a `ServerProvider` snapshot produced by the @@ -111,6 +117,7 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -163,16 +170,19 @@ export const CodexDriver: ProviderDriver = { Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings).pipe(Effect.map(stampIdentity)), + makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..c394a7d1b43 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -18,11 +18,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; @@ -45,6 +45,11 @@ import { makeStaticProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -66,7 +71,8 @@ export type CursorDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -98,6 +104,7 @@ export const CursorDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -130,21 +137,23 @@ export const CursorDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, // Model catalog and capabilities come exclusively from Cursor's // list_available_models extension method during provider checks. enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichCursorSnapshot({ - settings, + settings: settings.provider, snapshot: currentSnapshot, maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, stampIdentity, httpClient, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ab01439ffd3..d855d1a4515 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -5,11 +5,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; @@ -32,6 +32,11 @@ import { makeStaticProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); @@ -50,7 +55,8 @@ export type GrokDriverEnv = | HttpClient.HttpClient | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -80,6 +86,7 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -110,18 +117,20 @@ export const GrokDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshot = yield* makeManagedServerProvider({ + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialGrokProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) => + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichGrokSnapshot({ snapshot: currentSnapshot, maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, httpClient, }), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e7216f83366..6342d176590 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -19,12 +19,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { @@ -47,6 +47,11 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -80,7 +85,8 @@ export type OpenCodeDriverEnv = | OpenCodeRuntime | Path.Path | ProviderEventLoggers - | ServerConfig; + | ServerConfig + | ServerSettingsService; const withInstanceIdentity = (input: { @@ -111,6 +117,7 @@ export const OpenCodeDriver: ProviderDriver const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -142,21 +149,26 @@ export const OpenCodeDriver: ProviderDriver processEnv, ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); - const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), - streamSettings: Stream.never, - haveSettingsChanged: () => false, - initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), - refreshInterval: SNAPSHOT_REFRESH_INTERVAL, - }).pipe( + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>( + { + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }, + ).pipe( Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916c9d077dd..191bf8e27db 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -35,7 +35,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -298,6 +298,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("retains Claude session startup causes without exposing their messages", () => { + const cause = new Error("credential material that must remain in the cause chain"); + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + throw cause; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const error = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.equal(error.detail, "Failed to start Claude runtime session."); + assert.strictEqual(error.cause, cause); + assert.notMatch(error.message, /credential material/u); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("derives bypass permission mode from full-access runtime policy", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -395,7 +433,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.env?.HOME, path.join(os.homedir(), ".claude-work")); + assert.equal(createInput?.options.env?.HOME, NodePath.join(NodeOS.homedir(), ".claude-work")); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -649,7 +687,7 @@ describe("ClaudeAdapterLive", () => { }); it.effect("embeds image attachments in Claude user messages", () => { - const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ cwd: "/tmp/project-claude-attachments", baseDir, @@ -657,7 +695,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => - rmSync(baseDir, { + NodeFS.rmSync(baseDir, { recursive: true, force: true, }), @@ -674,9 +712,9 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment)); - mkdirSync(path.dirname(attachmentPath), { recursive: true }); - writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); const session = yield* adapter.startSession({ threadId: THREAD_ID, @@ -1365,19 +1403,14 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -1430,6 +1463,57 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps Claude stream failure events structural", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.fail(new Error("credential material that must stay in the cause chain")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal(runtimeError?.type, "runtime.error"); + if (runtimeError?.type === "runtime.error") { + assert.equal(runtimeError.payload.message, "Claude runtime stream failed."); + assert.deepEqual(runtimeError.payload.detail, { + failureCount: 1, + failureTags: ["ProviderAdapterProcessError"], + }); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Claude runtime stream failed."); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the previous session before replacing an existing thread session", () => { const queries: FakeClaudeQuery[] = []; const layer = Layer.effect( @@ -1542,14 +1626,12 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, () => Effect.void), - ); + const runtimeEventsFiber = yield* Stream.runForEach( + adapter.streamEvents, + () => Effect.void, + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c91f305b174..97a93f85829 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -249,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)) { @@ -297,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() @@ -1004,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, }), ), @@ -1242,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, }); } @@ -2910,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, + }), ), ), ), @@ -2938,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) { @@ -3004,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, + }), ), ); @@ -3522,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 d677de7a313..bd5f7ebffc4 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -31,7 +31,6 @@ import { buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -661,6 +660,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; + yield* Effect.logWarning("Claude Agent CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -673,7 +675,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Claude Agent CLI health check.", }, }); } @@ -698,7 +700,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); if (version.code !== 0) { - const detail = detailFromResult(version); + yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { + exitCode: version.code, + stdoutLength: version.stdout.length, + stderrLength: version.stderr.length, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -709,9 +715,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "error", auth: { status: "unknown" }, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", + message: "Claude Agent CLI is installed but failed to run.", }, }); } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7fef85c42e0..515a7c6fcbb 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,17 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "item.completed"); + NodeAssert.equal(firstEvent.value.type, "item.completed"); if (firstEvent.value.type !== "item.completed") { return; } - assert.equal(firstEvent.value.itemId, "msg_1"); - assert.equal(firstEvent.value.turnId, "turn-1"); - assert.equal(firstEvent.value.payload.itemType, "assistant_message"); + NodeAssert.equal(firstEvent.value.itemId, "msg_1"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + NodeAssert.equal(firstEvent.value.payload.itemType, "assistant_message"); }), ); @@ -524,13 +524,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); + NodeAssert.equal(firstEvent._tag, "Some"); if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { return; } - assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); - assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); - assert.deepStrictEqual(firstEvent.value.payload.data, { + NodeAssert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); + NodeAssert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); + NodeAssert.deepStrictEqual(firstEvent.value.payload.data, { completedAtMs: 1_778_000_000_000, threadId: "thread-1", turnId: "turn-1", @@ -578,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"); }), ); @@ -615,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"); }), ); @@ -646,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"); }), ); @@ -684,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"); }), ); @@ -715,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)", ); @@ -752,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"); }), ); @@ -784,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", ); @@ -824,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"); }), ); @@ -859,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"); }), ); @@ -895,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: [], }); }), @@ -934,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"); } }), ); @@ -1006,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", }); } @@ -1060,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, @@ -1119,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); }), ); }); @@ -1164,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; @@ -1208,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({ @@ -1225,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/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index fb2f36f6438..811c362f1e0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,7 +7,8 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -253,7 +254,7 @@ function parseCodexSkillsListResponse( } const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( - client: CodexClient.CodexAppServerClientShape, + client: CodexClient.CodexAppServerClient["Service"], ) { const models: ServerProviderModel[] = []; let cursor: string | null | undefined = undefined; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 2d303039856..06b7dd99bd4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -55,7 +55,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "never", sandboxPolicy: { @@ -97,7 +97,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "on-request", sandboxPolicy: { @@ -134,7 +134,7 @@ describe("buildTurnStartParams", () => { }), ); - assert.deepStrictEqual(params, { + NodeAssert.deepStrictEqual(params, { threadId: "provider-thread-1", approvalPolicy: "untrusted", sandboxPolicy: { @@ -156,19 +156,19 @@ describe("T3 browser developer instructions", () => { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, ]) { - assert.match(instructions, /t3-code/); - assert.match(instructions, /preview_status/); - assert.match(instructions, /preview_open/); - assert.match(instructions, /Do not switch to global browser skills/); + NodeAssert.match(instructions, /t3-code/); + NodeAssert.match(instructions, /preview_status/); + NodeAssert.match(instructions, /preview_open/); + NodeAssert.match(instructions, /Do not switch to global browser skills/); } }); }); describe("hasConfiguredMcpServer", () => { it("detects inline Codex MCP configuration arguments", () => { - assert.equal(hasConfiguredMcpServer(undefined), false); - assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); - assert.equal( + NodeAssert.equal(hasConfiguredMcpServer(undefined), false); + NodeAssert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); + NodeAssert.equal( hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), true, ); @@ -177,7 +177,7 @@ describe("hasConfiguredMcpServer", () => { describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -189,7 +189,7 @@ describe("isRecoverableThreadResumeError", () => { }); it("ignores non-recoverable resume errors", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -201,7 +201,7 @@ describe("isRecoverableThreadResumeError", () => { }); it("ignores unrelated missing-resource errors that do not mention threads", () => { - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -210,7 +210,7 @@ describe("isRecoverableThreadResumeError", () => { ), false, ); - assert.equal( + NodeAssert.equal( isRecoverableThreadResumeError( new CodexErrors.CodexAppServerRequestError({ code: -32603, @@ -256,8 +256,8 @@ describe("openCodexThread", () => { }), ); - assert.equal(opened.thread.id, "fresh-thread"); - assert.deepStrictEqual( + NodeAssert.equal(opened.thread.id, "fresh-thread"); + NodeAssert.deepStrictEqual( calls.map((call) => call.method), ["thread/resume", "thread/start"], ); @@ -283,7 +283,7 @@ describe("openCodexThread", () => { }, }; - await assert.rejects( + await NodeAssert.rejects( Effect.runPromise( openCodexThread({ client, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c71c6964459..9795e5a0680 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -36,8 +36,8 @@ class CursorAdapter extends Context.Service() "t3/provider/Layers/CursorAdapter.test/CursorAdapter", ) {} -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath] as const; @@ -45,8 +45,8 @@ async function makeMockAgentWrapper( extraEnv?: Record, options?: { initialDelaySeconds?: number }, ) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); - const wrapperPath = path.join(dir, "fake-agent.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -55,8 +55,8 @@ ${envExports} ${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } @@ -65,8 +65,8 @@ async function makeProbeWrapper( argvLogPath: string, extraEnv?: Record, ) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-")); - const wrapperPath = path.join(dir, "fake-agent.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-probe-")); + const wrapperPath = NodePath.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -77,13 +77,13 @@ export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} ${envExports} exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } async function readArgvLog(filePath: string) { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) @@ -92,7 +92,7 @@ async function readArgvLog(filePath: string) { } async function readJsonLines(filePath: string) { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); return raw .split("\n") .map((line) => line.trim()) @@ -103,7 +103,7 @@ async function readJsonLines(filePath: string) { async function waitForFileContent(filePath: string, attempts = 40) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { - const raw = await readFile(filePath, "utf8"); + const raw = await NodeFSP.readFile(filePath, "utf8"); if (raw.trim().length > 0) { return raw; } @@ -315,9 +315,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-stop-session-close"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ @@ -349,9 +349,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const settings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-concurrent-start-session"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-adapter-concurrent-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper( @@ -414,10 +414,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-plan-mode-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -470,10 +472,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-initial-config-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -713,10 +717,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const runtimeEvents: Array = []; const settledEventTypes = new Set(); const settledEventsReady = yield* Deferred.make(); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), ); @@ -931,10 +937,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-cancel-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), ); @@ -1192,10 +1200,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-model-switch"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -1255,10 +1265,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-fast-mode-reset"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); @@ -1339,10 +1351,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; const threadId = ThreadId.make("cursor-fast-mode-custom-instance"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1560332ad7f..9760b2f81fb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -36,7 +36,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -50,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, @@ -126,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; @@ -246,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: diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 35d5413714c..ff96ece9349 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,4 +1,4 @@ -import * as NodeOs from "node:os"; +import * as NodeOS from "node:os"; import type { CursorSettings, ModelCapabilities, @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -21,7 +21,8 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { createModelCapabilities, getProviderOptionBooleanSelectionValue, @@ -43,7 +44,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( @@ -416,12 +417,14 @@ const makeCursorAcpProbeRuntime = ( clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); }); const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, + useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, ) => makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( @@ -743,7 +746,7 @@ function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const configPath = path.join(NodeOs.homedir(), ".cursor", "cli-config.json"); + const configPath = path.join(NodeOS.homedir(), ".cursor", "cli-config.json"); const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => "")); return parseCursorCliConfigChannel(raw); }); @@ -1003,6 +1006,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, @@ -1015,7 +1021,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( 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)}.`, + : "Failed to execute Cursor Agent CLI health check.", }, }); } @@ -1071,7 +1077,7 @@ 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."; } else if (Option.isNone(discoveryExit.value)) { @@ -1106,6 +1112,7 @@ export const enrichCursorSnapshot = (input: { readonly settings: CursorSettings; readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; readonly httpClient: HttpClient.HttpClient; @@ -1117,14 +1124,16 @@ export const enrichCursorSnapshot = (input: { return Effect.void; } - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), ), Effect.catchCause((cause) => Effect.logWarning("Cursor version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.asVoid), ), ); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 0b1f99d3c11..71ac7831ed4 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -1,14 +1,18 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + function parseLogLine(line: string) { const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); assert.notEqual(match, null); @@ -29,10 +33,42 @@ function parseLogLine(line: string) { } describe("EventNdjsonLogger", () => { + it.effect("logs bounded diagnostics when an event cannot be serialized", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-circular-event-value"; + + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); + const circular: Record = { secret }; + circular.self = circular; + + try { + const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); + assert.exists(logger); + if (!logger) return; + yield* logger.write(circular, ThreadId.make("thread-1")); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + assert.include(serialized, '"errorTag":"SchemaError"'); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + it.effect("writes effect-style lines to thread-scoped files", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-native.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); @@ -51,13 +87,13 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const threadOnePath = path.join(tempDir, "thread-1.log"); - const threadTwoPath = path.join(tempDir, "thread-2.log"); - assert.equal(fs.existsSync(threadOnePath), true); - assert.equal(fs.existsSync(threadTwoPath), true); + const threadOnePath = NodePath.join(tempDir, "thread-1.log"); + const threadTwoPath = NodePath.join(tempDir, "thread-2.log"); + assert.equal(NodeFS.existsSync(threadOnePath), true); + assert.equal(NodeFS.existsSync(threadTwoPath), true); - const first = parseLogLine(fs.readFileSync(threadOnePath, "utf8").trim()); - const second = parseLogLine(fs.readFileSync(threadTwoPath, "utf8").trim()); + const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); + const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); assert.equal(first.stream, "NTIVE"); @@ -70,7 +106,7 @@ describe("EventNdjsonLogger", () => { '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', ); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); @@ -79,8 +115,8 @@ describe("EventNdjsonLogger", () => { "falls back to a global segment when orchestration thread id is missing or invalid", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-canonical.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); @@ -93,10 +129,9 @@ describe("EventNdjsonLogger", () => { yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); yield* logger.close(); - const globalPath = path.join(tempDir, "_global.log"); - assert.equal(fs.existsSync(globalPath), true); - const lines = fs - .readFileSync(globalPath, "utf8") + const globalPath = NodePath.join(tempDir, "_global.log"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() .split("\n") .map((line) => parseLogLine(line)); @@ -108,15 +143,15 @@ describe("EventNdjsonLogger", () => { assert.equal(lines[1]?.stream, "CANON"); assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); it.effect("serializes concurrent first writes for the same segment", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-canonical.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { @@ -137,10 +172,9 @@ describe("EventNdjsonLogger", () => { ); yield* logger.close(); - const globalPath = path.join(tempDir, "_global.log"); - assert.equal(fs.existsSync(globalPath), true); - const lines = fs - .readFileSync(globalPath, "utf8") + const globalPath = NodePath.join(tempDir, "_global.log"); + assert.equal(NodeFS.existsSync(globalPath), true); + const lines = NodeFS.readFileSync(globalPath, "utf8") .trim() .split("\n") .map((line) => parseLogLine(line)); @@ -151,15 +185,15 @@ describe("EventNdjsonLogger", () => { '{"id":"evt-concurrent-2"}', ]); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); it.effect("rotates per-thread files when max size is exceeded", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const basePath = path.join(tempDir, "provider-native.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "provider-native.ndjson"); try { const logger = yield* makeEventNdjsonLogger(basePath, { @@ -185,8 +219,7 @@ describe("EventNdjsonLogger", () => { yield* logger.close(); const fileStem = "thread-rotate.log"; - const matchingFiles = fs - .readdirSync(tempDir) + const matchingFiles = NodeFS.readdirSync(tempDir) .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) .toSorted(); @@ -203,7 +236,7 @@ describe("EventNdjsonLogger", () => { false, ); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); } }), ); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 04377ad520c..8c20a4c1936 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -6,11 +6,12 @@ * single effect-style text line in a thread-scoped file. Failures are * downgraded to warnings so provider runtime behavior is unaffected. */ -import fs from "node:fs"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ThreadId } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import { errorTag } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; @@ -31,8 +32,8 @@ export type EventNdjsonStream = "native" | "canonical" | "orchestration"; export interface EventNdjsonLogger { readonly filePath: string; - write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; - close: () => Effect.Effect; + write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + close: () => Effect.Effect; } export interface EventNdjsonLoggerOptions { @@ -91,9 +92,9 @@ const toLogMessage = Effect.fn("toLogMessage")(function* ( ): Effect.fn.Return { return yield* encodeUnknownJsonString(event).pipe( Effect.catch((error) => - logWarning("failed to serialize provider event log record", { error }).pipe( - Effect.as(undefined), - ), + logWarning("failed to serialize provider event log record", { + errorTag: errorTag(error), + }).pipe(Effect.as(undefined)), ), ); }); @@ -124,7 +125,7 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { if (!sinkResult.ok) { yield* logWarning("failed to initialize provider thread log file", { filePath: input.filePath, - error: sinkResult.error, + errorTag: errorTag(sinkResult.error), }); return undefined; } @@ -149,7 +150,7 @@ const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: { if (!flushResult.ok) { yield* logWarning("provider event log batch flush failed", { filePath: input.filePath, - error: flushResult.error, + errorTag: errorTag(flushResult.error), }); } }), @@ -178,7 +179,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function const directoryReady = yield* Effect.sync(() => { try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true }); return true; } catch (error) { return { ok: false as const, error }; @@ -187,7 +188,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function if (directoryReady !== true) { yield* logWarning("failed to create provider event log directory", { filePath, - error: directoryReady.error, + errorTag: errorTag(directoryReady.error), }); return undefined; } @@ -211,7 +212,7 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function } return makeThreadWriter({ - filePath: path.join(path.dirname(filePath), `${threadSegment}.log`), + filePath: NodePath.join(NodePath.dirname(filePath), `${threadSegment}.log`), maxBytes, maxFiles, batchWindowMs, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index bfd5ae25755..c871e3c2fc4 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -26,13 +26,13 @@ import { ServerConfig } from "../../config.ts"; import { makeGrokAdapter } from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = process.execPath; async function makeMockGrokWrapper(extraEnv?: Record) { - const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-")); - const wrapperPath = path.join(dir, "fake-grok.sh"); + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-grok.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); @@ -40,8 +40,8 @@ async function makeMockGrokWrapper(extraEnv?: Record) { ${envExports} exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" `; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); return wrapperPath; } @@ -51,7 +51,7 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect readFile(filePath, "utf8")).pipe( + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( Effect.orElseSucceed(() => ""), ); if (raw.trim().length > 0) { @@ -64,7 +64,7 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect line.trim()) @@ -149,9 +149,9 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "grok-adapter-exit-log-")), + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-adapter-exit-log-")), ); - const exitLogPath = path.join(tempDir, "exit.log"); + const exitLogPath = NodePath.join(tempDir, "exit.log"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ @@ -227,8 +227,10 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("responds to ACP approvals using provider-supplied option ids", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-custom-approval-option-id"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "grok-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index a21a2bb9fc7..40f425cbaa1 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -27,7 +27,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -41,7 +41,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, @@ -101,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; diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565e..000243869c9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -54,6 +54,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { + const secretStderr = "broken grok install: secret-token-value"; const snapshot = yield* Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -62,7 +63,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const grokPath = path.join(dir, "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"), + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), ); yield* fs.chmod(grokPath, 0o755); @@ -75,7 +76,8 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(true); expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("broken grok install"); + expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 35611398b4b..cf5d5ad9c8d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -6,7 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -19,7 +19,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -195,6 +194,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func if (Result.isFailure(versionResult)) { const error = versionResult.failure; + yield* Effect.logWarning("Grok CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -207,7 +209,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Grok CLI (`grok`) is not installed or not on PATH." - : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Grok CLI health check.", }, }); } @@ -231,7 +233,11 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const versionOutput = versionResult.success.value; const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); if (versionOutput.code !== 0) { - const detail = detailFromResult(versionOutput); + yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -242,9 +248,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: detail - ? `Grok CLI is installed but failed to run. ${detail}` - : "Grok CLI is installed but failed to run.", + message: "Grok CLI is installed but failed to run.", }, }); } @@ -254,8 +258,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); if (Exit.isFailure(discoveryExit)) { - const detail = Cause.pretty(discoveryExit.cause); - yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail }); + yield* Effect.logWarning("Grok ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -266,7 +271,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup failed. ${detail}`, + message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", }, }); } @@ -311,17 +316,20 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func export const enrichGrokSnapshot = (input: { readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly httpClient: HttpClient.HttpClient; }): Effect.Effect => { const { snapshot, publishSnapshot } = input; - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), Effect.catchCause((cause) => Effect.logWarning("Grok version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }), ), Effect.asVoid, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f483d8fd7e..d0475e25284 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -238,11 +238,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMode: "full-access", }); - assert.equal(session.provider, "opencode"); - assert.equal(session.threadId, "thread-opencode"); - assert.deepEqual(runtimeMock.state.startCalls, []); - assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - assert.deepEqual(runtimeMock.state.authHeaders, [ + NodeAssert.equal(session.provider, "opencode"); + NodeAssert.equal(session.threadId, "thread-opencode"); + NodeAssert.deepEqual(runtimeMock.state.startCalls, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ `Basic ${btoa("opencode:secret-password")}`, ]); }), @@ -259,8 +259,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); - assert.deepEqual(runtimeMock.state.startCalls, []); - assert.deepEqual( + NodeAssert.deepEqual(runtimeMock.state.startCalls, []); + NodeAssert.deepEqual( runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), true, ); @@ -286,7 +286,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(threadId); const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - assert.deepEqual( + NodeAssert.deepEqual( events.map((event) => event.type), ["session.started", "thread.started", "session.exited"], ); @@ -316,11 +316,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* Effect.exit(adapter.stopAll()); const sessions = yield* adapter.listSessions(); - assert.deepEqual(runtimeMock.state.closeCalls, [ + NodeAssert.deepEqual(runtimeMock.state.closeCalls, [ "http://127.0.0.1:9999", "http://127.0.0.1:9999", ]); - assert.deepEqual(sessions, []); + NodeAssert.deepEqual(sessions, []); }), ); @@ -348,7 +348,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { scopeClosed = true; const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); - assert.equal(Exit.hasInterrupts(exit), true); + NodeAssert.equal(Exit.hasInterrupts(exit), true); } finally { if (!scopeClosed) { yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); @@ -379,19 +379,19 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { .pipe(Effect.flip); const sessions = yield* adapter.listSessions(); - assert.equal(error._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); if (error._tag !== "ProviderAdapterRequestError") { throw new Error("Unexpected error type"); } - assert.equal(error.detail, "prompt failed"); - assert.equal( + NodeAssert.equal(error.detail, "prompt failed"); + NodeAssert.equal( error.message, "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", ); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.status, "ready"); - assert.equal(sessions[0]?.activeTurnId, undefined); - assert.equal(sessions[0]?.lastError, "prompt failed"); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.status, "ready"); + NodeAssert.equal(sessions[0]?.activeTurnId, undefined); + NodeAssert.equal(sessions[0]?.lastError, "prompt failed"); }), ); @@ -424,13 +424,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { model: "openai/gpt-5", }, }); - assert.equal(String(steeredTurn.turnId), String(turn.turnId)); + NodeAssert.equal(String(steeredTurn.turnId), String(turn.turnId)); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); - assert.equal(session?.status, "running"); - assert.equal(String(session?.activeTurnId), String(turn.turnId)); - assert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); }), ); @@ -466,11 +466,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { .pipe(Effect.flip); // The original turn keeps running — only the steer prompt failed. - assert.equal(error._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); - assert.equal(session?.status, "running"); - assert.equal(String(session?.activeTurnId), String(turn.turnId)); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); }), ); @@ -508,7 +508,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ), }); - assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { sessionID: "http://127.0.0.1:9999/session", model: { providerID: "anthropic", @@ -552,7 +552,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { input: "Fix it", }); - assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { sessionID: "http://127.0.0.1:9999/session", model: { providerID: "anthropic", @@ -596,15 +596,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }) .pipe(Effect.flip); - assert.equal(error._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(error._tag, "ProviderAdapterValidationError"); if (error._tag !== "ProviderAdapterValidationError") { throw new Error("Unexpected error type"); } - assert.equal( + NodeAssert.equal( error.issue, "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", ); - assert.deepEqual(runtimeMock.state.promptCalls, []); + NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); }).pipe(Effect.provide(adapterLayer)); }); @@ -631,10 +631,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const snapshot = yield* adapter.rollbackThread(threadId, 2); - assert.deepEqual(runtimeMock.state.revertCalls, [ + NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ { sessionID: "http://127.0.0.1:9999/session" }, ]); - assert.deepEqual(snapshot.turns, []); + NodeAssert.deepEqual(snapshot.turns, []); }), ); @@ -644,11 +644,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); - assert.deepEqual( + NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); - assert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); }), ); @@ -721,14 +721,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), ["A B", "Bonus"], ); - assert.equal(events.at(-1)?.type, "item.completed"); + NodeAssert.equal(events.at(-1)?.type, "item.completed"); const completed = events.at(-1); if (completed?.type === "item.completed") { - assert.equal(completed.payload.detail, "A BBonus"); + NodeAssert.equal(completed.payload.detail, "A BBonus"); } }), ); @@ -820,27 +820,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { return started; }).pipe(Effect.provide(adapterLayer)); - assert.equal(session.threadId, "thread-native-log"); - assert.equal(nativeEvents.length, 1); - assert.equal( + NodeAssert.equal(session.threadId, "thread-native-log"); + NodeAssert.equal(nativeEvents.length, 1); + NodeAssert.equal( nativeEvents.some((record) => record.event?.provider === "opencode"), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some( (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", ), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), true, ); - assert.equal( + NodeAssert.equal( nativeEvents.some((record) => record.event?.type === "message.updated"), true, ); - assert.equal( + NodeAssert.equal( nativeThreadIds.every((threadId) => threadId === "thread-native-log"), true, ); @@ -911,9 +911,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }; }).pipe(Effect.provide(adapterLayer)); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); - assert.deepEqual(closeCallsDuringRun, []); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + NodeAssert.deepEqual(closeCallsDuringRun, []); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index eac9f0b43fb..b0e785512dc 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -122,9 +122,12 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, false); - assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); }), ); @@ -133,9 +136,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); }), ); @@ -174,20 +177,20 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); - assert.ok(model); + NodeAssert.ok(model); const variantDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "variant" && descriptor.type === "select", ); - assert.ok(variantDescriptor && variantDescriptor.type === "select"); - assert.equal( + NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); + NodeAssert.equal( variantDescriptor.options.find((option) => option.isDefault === true)?.id, "medium", ); const agentDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); - assert.ok(agentDescriptor && agentDescriptor.type === "select"); - assert.equal( + NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); + NodeAssert.equal( agentDescriptor.options.find((option) => option.isDefault === true)?.id, "build", ); @@ -198,7 +201,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - assert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); }), ); }); @@ -215,9 +218,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i process.cwd(), ); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal( + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( snapshot.message, "OpenCode server rejected authentication. Check the server URL and password.", ); @@ -237,9 +240,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i process.cwd(), ); - assert.equal(snapshot.status, "error"); - assert.equal(snapshot.installed, true); - assert.equal( + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( snapshot.message, "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 7fb545b2bed..c4145ecf1a0 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -10,16 +10,16 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; -import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; +import type * as CodexAdapter from "../Services/CodexAdapter.ts"; +import type * as CursorAdapter from "../Services/CursorAdapter.ts"; +import type * as OpenCodeAdapter from "../Services/OpenCodeAdapter.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; -import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; +import type * as TextGeneration from "../../textGeneration/TextGeneration.ts"; +import * as ProviderAdapterRegistryLayer from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); @@ -27,7 +27,7 @@ const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); -const fakeCodexAdapter: CodexAdapterShape = { +const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { provider: CODEX_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -44,7 +44,7 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; -const fakeClaudeAdapter: ClaudeAdapterShape = { +const fakeClaudeAdapter: ClaudeAdapter.ClaudeAdapterShape = { provider: CLAUDE_AGENT_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -61,7 +61,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenCodeAdapter: OpenCodeAdapterShape = { +const fakeOpenCodeAdapter: OpenCodeAdapter.OpenCodeAdapterShape = { provider: OPENCODE_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -78,7 +78,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; -const fakeCursorAdapter: CursorAdapterShape = { +const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = { provider: CURSOR_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), @@ -124,7 +124,7 @@ const makeFakeInstance = ( streamChanges: Stream.empty, }, adapter, - textGeneration: {} as unknown as TextGenerationShape, + textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"], }; }; @@ -135,7 +135,7 @@ const fakeInstances: ReadonlyArray = [ makeFakeInstance("cursor", fakeCursorAdapter), ]; -const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { +const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { getInstance: (instanceId) => Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)), listInstances: Effect.succeed(fakeInstances), @@ -147,14 +147,17 @@ const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { }); const layer = Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), + Layer.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive, + fakeInstanceRegistryLayer, + ), NodeServices.layer, ); it.layer(layer)("ProviderAdapterRegistryLive", (it) => { it("resolves adapters and routing metadata from provider instances", () => Effect.gen(function* () { - const registry = yield* ProviderAdapterRegistry; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); const adapter = yield* registry.getByInstance(claudeInstanceId); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 4e43e04cb7c..0fd88b4262a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -114,11 +114,7 @@ export const deriveProviderInstanceConfigMap = ( * configs, so the only way the watcher could fail is a settings stream * tear-down, which logs and exits cleanly. */ -const SettingsWatcherLive: Layer.Layer< - never, - never, - ProviderInstanceRegistryMutator | ServerSettingsService -> = Layer.effectDiscard( +const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index f2c5892a2c6..dbfa7faffea 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -39,6 +39,7 @@ import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; @@ -107,6 +108,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); @@ -244,6 +246,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { prefix: "provider-instance-registry-all-drivers-test", }).pipe( Layer.provideMerge(infraLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5fe0f903686..b3ab1145495 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -32,8 +32,8 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { haveProvidersChanged, @@ -42,12 +42,12 @@ import { ProviderRegistryLive, selectProvidersByKind, } from "./ProviderRegistry.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; -import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; -import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; +import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -294,11 +294,11 @@ function makeMutableServerSettingsService( get streamChanges() { return Stream.fromPubSub(changes); }, - } satisfies ServerSettingsShape; + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; }); } -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))( +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { @@ -636,14 +636,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -658,7 +661,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); }).pipe(Effect.provide(runtimeServices)); @@ -786,16 +789,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -811,8 +817,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const config = yield* ServerConfig; + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; const filePath = yield* resolveProviderStatusCachePath({ cacheDir: config.providerStatusCacheDir, instanceId: cursorInstanceId, @@ -880,16 +886,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -905,7 +914,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); @@ -975,25 +984,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const instancesRef = yield* Ref.make>([codexInstance]); const failNextList = yield* Ref.make(false); const wait = () => Effect.yieldNow; - const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }); + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }, + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( @@ -1009,7 +1021,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); yield* Ref.set(failNextList, true); @@ -1092,15 +1104,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will // genuinely spawn a subprocess. The missing-binary ENOENT is @@ -1112,7 +1131,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; let providers = yield* registry.getProviders; for ( let attempts = 0; @@ -1177,15 +1196,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1199,7 +1225,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the // snapshot should be `status: "error"`. @@ -1291,15 +1317,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1307,7 +1340,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; const providers = yield* registry.getProviders; const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); @@ -1345,15 +1378,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), + ), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-provider-registry-", }), ), Layer.provideMerge(TestHttpClientLive), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "agent") { @@ -1380,13 +1420,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); const runtimeServices = yield* Layer.build( Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), providerRegistryLayer, ), ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; + const registry = yield* ProviderRegistry.ProviderRegistry; const providers = yield* registry.getProviders; const cursorProvider = providers.find( (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), @@ -1835,14 +1875,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { + it.effect("returns error when version check fails with non-zero exit code", () => { + const secretStderr = "Something went wrong: secret-token-value"; + return Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); + assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); + assert.ok(!(status.message ?? "").includes(secretStderr)); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -1850,14 +1893,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if (joined === "--version") return { stdout: "", - stderr: "Something went wrong", + stderr: secretStderr, code: 1, }; throw new Error(`Unexpected args: ${joined}`); }), ), - ), - ); + ); + }); it.effect("returns warning when the Claude initialization result is unavailable", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6a72bf69941..ccbbce1759f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import type { ProviderApprovalDecision, @@ -43,27 +43,23 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { - ProviderAdapterRegistry, - type ProviderAdapterRegistryShape, -} from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderService } from "../Services/ProviderService.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderService from "../Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; -const defaultServerSettingsLayer = ServerSettingsService.layerTest(); +const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -281,8 +277,11 @@ function makeProviderServiceLayer() { [ProviderDriverKind.make("cursor")]: cursor.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -294,7 +293,12 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ), directoryLayer, @@ -326,8 +330,11 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const registry = makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -337,7 +344,12 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ), directoryLayer, runtimeRepositoryLayer, @@ -346,7 +358,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const scope = yield* Scope.make(); const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); - yield* ProviderService.pipe(Effect.provide(runtimeServices)); + yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices)); const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); assert.equal(Exit.isSuccess(closeExit), true); @@ -362,7 +374,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () [CODEX_DRIVER]: codex.adapter, [CLAUDE_AGENT_DRIVER]: claude.adapter, }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { ...registryBase, getInstanceInfo: (instanceId) => instanceId === claudeAgentInstanceId @@ -378,8 +390,11 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () }) : registryBase.getInstanceInfo(instanceId), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -388,12 +403,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const failure = yield* Effect.flip( Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-disabled"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -420,7 +440,7 @@ it.effect( new ProviderUnsupportedError({ provider: driverKind, }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { getByInstance: (requestedInstanceId) => requestedInstanceId === instanceId ? Effect.succeed(codex.adapter) @@ -445,15 +465,18 @@ it.effect( PubSub.subscribe(pubsub), ), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const serverSettingsLayer = ServerSettingsService.layerTest({ + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest({ providers: { codex: { enabled: false, }, }, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -464,11 +487,16 @@ it.effect( Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const session = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-enabled-custom"), { provider: driverKind, providerInstanceId: instanceId, @@ -491,7 +519,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance new ProviderUnsupportedError({ provider: ProviderDriverKind.make("codex"), }); - const registry: ProviderAdapterRegistryShape = { + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { getByInstance: (requestedInstanceId) => requestedInstanceId === instanceId ? Effect.succeed(codex.adapter) @@ -516,8 +544,11 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance PubSub.subscribe(pubsub), ), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -526,12 +557,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const failure = yield* Effect.flip( Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-disabled-instance"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: instanceId, @@ -557,7 +593,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const registry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: codex.adapter, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -572,15 +608,20 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se close: () => Effect.void, }, }).pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); yield* Effect.gen(function* () { - yield* ProviderService; + yield* ProviderService.ProviderService; yield* advanceTestClock(10); codex.emit({ eventId: asEventId("evt-canonical-thread-segment"), @@ -603,8 +644,8 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-service-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); const registry = makeAdapterRegistryMock({ @@ -612,13 +653,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -627,23 +668,28 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(directoryLayer)); const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); - yield* ProviderService.pipe(Effect.provide(providerLayer)); + yield* ProviderService.ProviderService.pipe(Effect.provide(providerLayer)); const persistedProvider = yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; return yield* directory.getProvider(asThreadId("thread-stale")); }).pipe(Effect.provide(directoryLayer)); assert.equal(persistedProvider, "codex"); const runtime = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale"), }); @@ -660,7 +706,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(persistenceLayer)); assert.equal(legacyTableRows.length, 0); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -668,10 +714,12 @@ it.effect( "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-restart-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -684,11 +732,18 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -698,7 +753,7 @@ it.effect( }; const startedSession = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-1"); const session = yield* provider.startSession(threadId, { provider: ProviderDriverKind.make("codex"), @@ -717,7 +772,7 @@ it.effect( }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: startedSession.threadId, }); @@ -736,18 +791,25 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondCodex.startSession.mockClear(); secondCodex.rollbackThread.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.rollbackConversation({ threadId: startedSession.threadId, numTurns: 1, @@ -774,14 +836,14 @@ it.effect( assert.equal(typeof rollbackCall?.[0], "string"); assert.equal(rollbackCall?.[1], 1); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -867,7 +929,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -908,8 +970,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { provider: ProviderDriverKind.make("codex"), @@ -960,7 +1022,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-claude"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -989,8 +1051,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("dies when an active session conflicts with its persisted binding", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const directory = yield* ProviderSessionDirectory; + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const threadId = asThreadId("thread-binding-mismatch"); yield* provider.startSession(threadId, { @@ -1020,7 +1082,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("stops stale sessions in other providers after a successful replacement start", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-provider-replacement"); const codexSession = yield* provider.startSession(threadId, { @@ -1059,7 +1121,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -1100,7 +1162,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1153,7 +1215,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), @@ -1178,8 +1240,8 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("persists runtime status transitions in provider_session_runtime", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = asThreadId("thread-runtime-status"); const session = yield* provider.startSession(threadId, { @@ -1223,10 +1285,12 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-start-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1238,15 +1302,22 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const initial = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-claude-start"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1257,7 +1328,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }).pipe(Effect.provide(firstProviderLayer)); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.listSessions(); }).pipe(Effect.provide(firstProviderLayer)); @@ -1269,17 +1340,24 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondClaude.startSession.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(initial.threadId, { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1305,7 +1383,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(startPayload.threadId, initial.threadId); } - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -1313,10 +1391,12 @@ routing.layer("ProviderServiceLive routing", (it) => { "reuses persisted cwd when startSession resumes a claude session without cwd input", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-service-cwd-"), + ); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1328,15 +1408,22 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), + ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); const initial = yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(asThreadId("thread-claude-cwd"), { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1354,17 +1441,24 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide( + Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), + ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), - Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ); secondClaude.startSession.mockClear(); yield* Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; yield* provider.startSession(initial.threadId, { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, @@ -1389,7 +1483,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(startPayload.threadId, initial.threadId); } - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); }); @@ -1398,7 +1492,7 @@ const fanout = makeProviderServiceLayer(); fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("fans out adapter turn completion events", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1444,7 +1538,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1500,7 +1594,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, @@ -1572,7 +1666,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("records provider metrics with the routed provider label", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-metrics"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1650,7 +1744,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { "records sendTurn metrics with the resolved provider when modelSelection is omitted", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { provider: ProviderDriverKind.make("claudeAgent"), @@ -1691,7 +1785,7 @@ const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { it.effect("rejects session starts without an explicit provider instance id", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; validation.codex.startSession.mockClear(); const failure = yield* Effect.flip( @@ -1710,7 +1804,7 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("rejects mismatched provider kind and provider instance id", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; validation.codex.startSession.mockClear(); validation.claude.startSession.mockClear(); @@ -1735,7 +1829,7 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("returns ProviderValidationError for invalid input payloads", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const failure = yield* Effect.result( provider.startSession(asThreadId("thread-validation"), { @@ -1760,8 +1854,8 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("accepts startSession when adapter has not emitted provider thread id yet", () => Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ecb1dd2dbd3..2eaaeb8ce3c 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,12 @@ import { } from "../../observability/Metrics.ts"; import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; -import { - ProviderSessionDirectory, - type ProviderRuntimeBinding, -} from "../Services/ProviderSessionDirectory.ts"; +import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; +import * as ProviderService from "../Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; const isModelSelection = Schema.is(ModelSelection); @@ -69,6 +66,9 @@ export interface ProviderServiceLiveOptions { readonly canonicalEventLogger?: EventNdjsonLogger; } +type ProviderServiceMethod = + ProviderService.ProviderService["Service"][Name]; + const ProviderRollbackConversationInput = Schema.Struct({ threadId: ThreadId, numTurns: NonNegativeInt, @@ -141,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; @@ -151,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; @@ -202,16 +202,16 @@ const correlateRuntimeEventWithInstance = ( const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { - const analytics = yield* Effect.service(AnalyticsService); - const eventLoggers = yield* ProviderEventLoggers; + const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical // log writer is attached", which downstream code already handles as a // no-op. const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; - const registry = yield* ProviderAdapterRegistry; - const directory = yield* ProviderSessionDirectory; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => @@ -353,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); @@ -519,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", @@ -642,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, @@ -717,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", @@ -754,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", @@ -792,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({ @@ -826,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", @@ -874,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]) => @@ -895,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) { @@ -952,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({ @@ -1052,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), + }), ), ), ); @@ -1071,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 f9793ca9d1f..079b7f10ebf 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; @@ -16,15 +16,12 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), @@ -36,7 +33,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initialThreadId = ThreadId.make("thread-1"); @@ -83,7 +80,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -128,7 +125,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const olderThreadId = ThreadId.make("thread-runtime-older"); const newerThreadId = ThreadId.make("thread-runtime-newer"); @@ -202,7 +199,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-provider-change"); yield* runtimeRepository.upsert({ @@ -232,8 +229,8 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); + const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); const threadId = ThreadId.make("thread-restart"); @@ -269,6 +266,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(legacyTableRows.length, 0); }).pipe(Effect.provide(directoryLayer)); - fs.rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 0508f6c8cb3..23075bd9a06 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -5,8 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, @@ -59,7 +58,7 @@ function mergeRuntimePayload( } function toRuntimeBinding( - runtime: ProviderSessionRuntime, + runtime: ProviderSessionRuntime.ProviderSessionRuntime, operation: string, ): Effect.Effect { return decodeProviderDriverKind(runtime.providerName, operation).pipe( @@ -85,7 +84,7 @@ function toRuntimeBinding( } const makeProviderSessionDirectory = Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 18e6166c1cd..e976c183a43 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 3a57f374de4..c738882c23a 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -30,7 +30,7 @@ import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; -import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; +import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -70,7 +70,7 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; - readonly textGeneration: TextGenerationShape; + readonly textGeneration: TextGeneration.TextGeneration["Service"]; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index f2e286c589c..5533a04bc83 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -10,19 +10,19 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; -import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; describe("AcpSessionRuntime", () => { it.effect("merges custom initialize client capabilities into the ACP handshake", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const initializeStarted = requestEvents.find( @@ -64,7 +64,7 @@ describe("AcpSessionRuntime", () => { it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); @@ -115,7 +115,7 @@ describe("AcpSessionRuntime", () => { it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ @@ -176,7 +176,7 @@ describe("AcpSessionRuntime", () => { it.effect("suppresses generic placeholder tool updates until completion", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const promptResult = yield* runtime.prompt({ @@ -213,9 +213,9 @@ describe("AcpSessionRuntime", () => { ); it.effect("logs ACP requests from the shared runtime", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.setModel("composer-2"); @@ -265,9 +265,9 @@ describe("AcpSessionRuntime", () => { }); it.effect("skips no-op session config writes when the requested value is already active", () => { - const requestEvents: Array = []; + const requestEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.setConfigOption("model", "default"); @@ -302,7 +302,7 @@ describe("AcpSessionRuntime", () => { it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { const protocolEvents: Array = []; return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); yield* runtime.prompt({ @@ -347,10 +347,10 @@ describe("AcpSessionRuntime", () => { }); it.effect("rejects invalid config option values before sending session/set_config_option", () => { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); - const requestLogPath = path.join(tempDir, "requests.ndjson"); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); @@ -363,7 +363,7 @@ describe("AcpSessionRuntime", () => { expect(error.message).toContain("composer-2[fast=true]"); } - const recordedRequests = readFileSync(requestLogPath, "utf8") + const recordedRequests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -392,7 +392,7 @@ describe("AcpSessionRuntime", () => { ), Effect.scoped, Effect.provide(NodeServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); }); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts new file mode 100644 index 00000000000..8c92d523aee --- /dev/null +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; +import * as AcpErrors from "effect-acp/errors"; + +import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; + +const nodeServicesIt = it.layer(NodeServices.layer); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +nodeServicesIt("ACP native logging", (it) => { + it.effect("records bounded request and protocol diagnostics without raw payloads", () => + Effect.gen(function* () { + const records: Array = []; + const nativeEventLogger: EventNdjsonLogger = { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const secret = "secret-token-value"; + const requestLogger = logger.requestLogger; + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(requestLogger); + assert.exists(protocolLogger); + if (!requestLogger || !protocolLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: { prompt: secret, sessionId: secret }, + status: "failed", + cause: Cause.fail(AcpErrors.AcpRequestError.internalError(secret, { token: secret })), + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `{"token":"${secret}"}`, + }); + yield* protocolLogger({ + direction: "outgoing", + stage: "decoded", + payload: { + _tag: "Request", + tag: "session/prompt", + payload: { prompt: secret }, + }, + }); + + const serialized = encodeUnknownJson(records); + assert.notInclude(serialized, secret); + assert.include(serialized, '"method":"session/prompt"'); + assert.include(serialized, '"errorTag":"AcpRequestError"'); + assert.include(serialized, '"reasonCount":1'); + assert.include(serialized, '"valueType":"string"'); + assert.include(serialized, '"messageTag":"Request"'); + }), + ); + + it.effect("logs a structural tag when the native writer defects", () => { + const messages: Array = []; + const logCapture = Logger.make(({ message }) => { + if (Array.isArray(message)) { + messages.push(...message); + } else { + messages.push(message); + } + }); + const secret = "secret-writer-failure"; + + return Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.die(new Error(secret)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + + const serialized = encodeUnknownJson(messages); + assert.notInclude(serialized, secret); + assert.include(serialized, '"errorTag":"Die"'); + assert.include(serialized, '"reasonCount":1'); + }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); + }); + + it.effect("preserves native writer interruption", () => + Effect.gen(function* () { + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: () => Effect.interrupt, + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + }); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + + const exit = yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); + } + }), + ); +}); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 6146980e4fb..06bff3aa611 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -1,4 +1,5 @@ import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -6,15 +7,60 @@ import * as Effect from "effect/Effect"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; -import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; -function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { +function structuralMethod(value: string): string { + return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; +} + +function summarizePayload(payload: unknown): Readonly> { + if (payload === null) return { valueType: "null" }; + if (typeof payload === "string") { + return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; + } + if (payload instanceof Uint8Array) { + return { valueType: "bytes", byteLength: payload.byteLength }; + } + if (Array.isArray(payload)) { + return { valueType: "array", itemCount: payload.length }; + } + if (typeof payload !== "object") { + return { valueType: typeof payload }; + } + + try { + const record = payload as Record; + return { + valueType: "object", + fieldCount: Object.keys(record).length, + ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), + ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), + }; + } catch { + return { valueType: "object" }; + } +} + +function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { return { - method: event.method, + method: structuralMethod(event.method), status: event.status, - request: event.payload, - ...(event.result !== undefined ? { result: event.result } : {}), - ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}), + request: summarizePayload(event.payload), + ...(event.result !== undefined ? { result: summarizePayload(event.result) } : {}), + ...(event.cause !== undefined + ? { + errorTag: causeErrorTag(event.cause), + reasonCount: event.cause.reasons.length, + } + : {}), + }; +} + +function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) { + return { + direction: event.direction, + stage: event.stage, + payload: summarizePayload(event.payload), }; } @@ -24,7 +70,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; - }): Pick => { + }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; readonly payload: unknown; @@ -47,17 +93,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" input.threadId, ); }).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to write native ACP event log.", { - cause, - provider: input.provider, - threadId: input.threadId, - }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to write native ACP event log.", { + errorTag: causeErrorTag(cause), + reasonCount: cause.reasons.length, + provider: input.provider, + threadId: input.threadId, + }), ), ); return { - requestLogger: (event: AcpSessionRequestLogEvent) => + requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => writeNativeAcpLog({ kind: "request", payload: formatRequestLogPayload(event), @@ -70,9 +119,9 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => writeNativeAcpLog({ kind: "protocol", - payload: event, + payload: formatProtocolLogPayload(event), }), - } satisfies NonNullable, + } satisfies NonNullable, } : {}), }; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b8097f10b75..4fc2c443e11 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -6,9 +7,9 @@ import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; -import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -75,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 {} @@ -140,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 > => @@ -573,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 07b68d9815a..eebe5ddd92e 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -9,12 +9,12 @@ import * as Effect from "effect/Effect"; import { describe, expect } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { it.effect("initialize and authenticate against real agent acp", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toBeDefined(); }).pipe( @@ -42,7 +42,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/new returns configOptions with a model selector", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); const result = started.sessionSetupResult; // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -97,7 +97,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/set_config_option switches the model in-session", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; const started = yield* runtime.start(); const newResult = started.sessionSetupResult; diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 5893c33215d..169d7c6206d 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 642548832fa..ee8af1e5266 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -2,17 +2,12 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { - AcpSessionRuntime, - type AcpSessionRuntimeOptions, - type AcpSessionRuntimeShape, - type AcpSpawnInput, -} from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; @@ -24,7 +19,7 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); type GrokAcpRuntimeGrokSettings = Pick; interface GrokAcpRuntimeInput extends Omit< - AcpSessionRuntimeOptions, + AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -36,7 +31,7 @@ export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, -): AcpSpawnInput { +): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", args: ["agent", "stdio"], @@ -56,7 +51,11 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st export const makeGrokAcpRuntime = ( input: GrokAcpRuntimeInput, -): Effect.Effect => +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope +> => Effect.gen(function* () { const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ @@ -69,7 +68,9 @@ export const makeGrokAcpRuntime = ( ), ), ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); }); export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { @@ -88,7 +89,7 @@ export function currentGrokModelIdFromSessionSetup( } export function applyGrokAcpModelSelection(input: { - readonly runtime: Pick; + readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 88547fb3afa..bbf301fa407 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -21,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Settings, >(input: { readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; - readonly getSettings: Effect.Effect; + readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 365884da85d..a83c134d5bd 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,4 +1,4 @@ -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; import { @@ -206,7 +206,7 @@ export function toOpenCodeFileParts(input: { type: "file", mime: attachment.mimeType, filename: attachment.name, - url: pathToFileURL(attachmentPath).href, + url: NodeURL.pathToFileURL(attachmentPath).href, }); } diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index c4ad2fa7509..8937844f613 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,16 +1,17 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; -import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; -import path from "node:path"; -import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodePath from "node:path"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; import { createProviderVersionAdvisory, + enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, @@ -24,7 +25,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); const makeTempDir = (name: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.map((id) => path.join(NodeOS.tmpdir(), `${name}-${id}`)), + Effect.map((id) => NodePath.join(NodeOS.tmpdir(), `${name}-${id}`)), ); const isNativeTestCommandPath = (expectedPathSegment: string) => @@ -67,6 +68,19 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); +const installedPackageToolProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("packageTool"), + driver: driver("packageTool"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("reads cached versions through the injectable cache reference", () => @@ -95,6 +109,32 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect("does not fetch latest provider versions when update checks are disabled", () => + enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + packageToolUpdate.resolve(), + { + enableProviderUpdateChecks: false, + }, + ).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("disabled provider update checks should not make an HTTP request"), + ), + ), + Effect.map((provider) => { + expect(provider.versionAdvisory).toMatchObject({ + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + checkedAt: "2026-04-10T00:00:00.000Z", + }); + }), + ), + ); + it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ @@ -163,11 +203,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-vite-plus-capabilities"); - const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); - mkdirSync(vitePlusBinDir, { recursive: true }); - const packageToolPath = path.join(vitePlusBinDir, "package-tool"); - writeFileSync(packageToolPath, "#!/bin/sh\n"); - chmodSync(packageToolPath, 0o755); + const vitePlusBinDir = NodePath.join(tempDir, ".vite-plus", "bin"); + NodeFS.mkdirSync(vitePlusBinDir, { recursive: true }); + const packageToolPath = NodePath.join(vitePlusBinDir, "package-tool"); + NodeFS.writeFileSync(packageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(packageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, @@ -200,9 +240,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-bun-capabilities"); - const bunBinDir = path.join(tempDir, ".bun", "bin"); - mkdirSync(bunBinDir, { recursive: true }); - writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); + const bunBinDir = NodePath.join(tempDir, ".bun", "bin"); + NodeFS.mkdirSync(bunBinDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(bunBinDir, "native-package-tool.exe"), "MZ"); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( nativePackageToolUpdate, @@ -236,11 +276,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); - const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); - mkdirSync(pnpmHomeDir, { recursive: true }); - const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool"); - writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); - chmodSync(scopedPackageToolPath, 0o755); + const pnpmHomeDir = NodePath.join(tempDir, ".local", "share", "pnpm"); + NodeFS.mkdirSync(pnpmHomeDir, { recursive: true }); + const scopedPackageToolPath = NodePath.join(pnpmHomeDir, "scoped-package-tool"); + NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(scopedPackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( scopedPackageToolUpdate, @@ -296,11 +336,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities"); - const nativeBinDir = path.join(tempDir, ".local", "bin"); - mkdirSync(nativeBinDir, { recursive: true }); - const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool"); - writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); - chmodSync(nativePackageToolPath, 0o755); + const nativeBinDir = NodePath.join(tempDir, ".local", "bin"); + NodeFS.mkdirSync(nativeBinDir, { recursive: true }); + const nativePackageToolPath = NodePath.join(nativeBinDir, "native-package-tool"); + NodeFS.writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(nativePackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( nativePackageToolUpdate, @@ -333,11 +373,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities"); - const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin"); - mkdirSync(nativeBinDir, { recursive: true }); - const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool"); - writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); - chmodSync(scopedPackageToolPath, 0o755); + const nativeBinDir = NodePath.join(tempDir, ".scoped-package-tool", "bin"); + NodeFS.mkdirSync(nativeBinDir, { recursive: true }); + const scopedPackageToolPath = NodePath.join(nativeBinDir, "scoped-package-tool"); + NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + NodeFS.chmodSync(scopedPackageToolPath, 0o755); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( scopedPackageToolUpdate, @@ -414,8 +454,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-capabilities"); - const binDir = path.join(tempDir, "bin"); - const packageBinDir = path.join( + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( tempDir, "lib", "node_modules", @@ -423,13 +463,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", "bin", ); - mkdirSync(binDir, { recursive: true }); - mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = path.join(packageBinDir, "package-tool.js"); - const symlinkPath = path.join(binDir, "package-tool"); - writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - chmodSync(packageBinPath, 0o755); - symlinkSync(packageBinPath, symlinkPath); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, @@ -457,8 +497,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); - const binDir = path.join(tempDir, "bin"); - const packageBinDir = path.join( + const binDir = NodePath.join(tempDir, "bin"); + const packageBinDir = NodePath.join( tempDir, ".local", "share", @@ -470,13 +510,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", "bin", ); - mkdirSync(binDir, { recursive: true }); - mkdirSync(packageBinDir, { recursive: true }); - const packageBinPath = path.join(packageBinDir, "package-tool.js"); - const symlinkPath = path.join(binDir, "package-tool"); - writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); - chmodSync(packageBinPath, 0o755); - symlinkSync(packageBinPath, symlinkPath); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = NodePath.join(packageBinDir, "package-tool.js"); + const symlinkPath = NodePath.join(binDir, "package-tool"); + NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + NodeFS.chmodSync(packageBinPath, 0o755); + NodeFS.symlinkSync(packageBinPath, symlinkPath); const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d1c4a7d6a71..8645f9f943c 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -468,10 +468,21 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( "enrichProviderSnapshotWithVersionAdvisory", -)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) { +)(function* ( + snapshot: ServerProvider, + maintenanceCapabilities?: ProviderMaintenanceCapabilities, + options?: { + readonly enableProviderUpdateChecks: boolean | undefined; + }, +) { const capabilities = maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); - if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + const shouldResolveLatestVersion = + options?.enableProviderUpdateChecks !== false && + snapshot.enabled && + snapshot.installed && + Boolean(snapshot.version); + if (!shouldResolveLatestVersion) { return { ...snapshot, versionAdvisory: createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index fdc8b4c4a71..abe138fdfb9 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, +} from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("ProviderCommandNotFoundError", () => { + it("classifies normalized platform failures without parsing messages", () => { + expect( + isCommandMissingCause( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "arbitrary host detail", + }), + ), + ).toBe(true); + expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); + }); + + it.effect("retains safe failed-command diagnostics without process output", () => { + const stderr = "'codex' is not recognized: secret-token-value"; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + return Effect.gen(function* () { + const error = yield* spawnAndCollect( + "C:\\tools\\codex.cmd", + ChildProcess.make("codex", ["--version"]), + ).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.flip, + ); + + if (error._tag !== "ProviderCommandNotFoundError") { + throw new Error(`Unexpected error: ${error._tag}`); + } + + expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); + expect(error.exitCode).toBe(9009); + expect(error.stdoutLength).toBe(0); + expect(error.stderrLength).toBe(stderr.length); + expect(error.message).toBe( + "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", + ); + expect(isCommandMissingCause(error)).toBe(true); + expect(error).not.toHaveProperty("stdout"); + expect(error).not.toHaveProperty("stderr"); + expect(error.message).not.toContain("secret-token-value"); + }); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2ecb3220773..dfe31ffdc44 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,7 +9,8 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Data from "effect/Data"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -27,11 +28,21 @@ export interface CommandResult { readonly code: number; } -export class ProviderCommandExecutionError extends Data.TaggedError( - "ProviderCommandExecutionError", -)<{ - readonly message: string; -}> {} +export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass()( + "ProviderCommandNotFoundError", + { + binaryPath: Schema.String, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`; + } +} + +const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); export interface ProviderProbeResult { readonly installed: boolean; @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: { readonly message: string }): boolean { - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); +export function isCommandMissingCause(error: unknown): boolean { + if (isProviderCommandNotFoundError(error)) return true; + return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman const result: CommandResult = { stdout, stderr, code: exitCode }; if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); + return yield* new ProviderCommandNotFoundError({ + binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); } return result; }).pipe(Effect.scoped); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 64cb9ccd417..07f67cd7de8 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -9,6 +9,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; import { hydrateCachedProvider, @@ -42,6 +43,39 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect("logs structural diagnostics without retaining invalid cache contents", () => { + const messages: Array = []; + const logger = Logger.make((options) => { + if (Array.isArray(options.message)) { + messages.push(...options.message); + } else { + messages.push(options.message); + } + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); + const cachePath = `${tempDir}/provider.json`; + const secretCacheValue = "secret-cache-value"; + yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); + + const result = yield* readProviderStatusCache(cachePath); + + assert.strictEqual(result, undefined); + const failure = messages.find( + (message): message is Record => + typeof message === "object" && message !== null && "path" in message, + ); + assert.exists(failure); + assert.strictEqual(failure.path, cachePath); + assert.strictEqual(typeof failure.errorTag, "string"); + assert.ok(!("cause" in failure)); + assert.ok(!("issues" in failure)); + assert.ok(!Object.values(failure).map(String).join("\n").includes(secretCacheValue)); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 0b9b365f360..2fe0424b4f5 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -4,7 +4,7 @@ import { type ServerProvider, ServerProvider as ServerProviderSchema, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -134,7 +134,7 @@ export const readProviderStatusCache = (filePath: string) => onFailure: (cause) => Effect.logWarning("failed to parse provider status cache, ignoring", { path: filePath, - issues: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.as(undefined)), onSuccess: Effect.succeed, }), diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts new file mode 100644 index 00000000000..308d84a1446 --- /dev/null +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -0,0 +1,43 @@ +import type { ServerSettings, ServerSettingsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Stream from "effect/Stream"; + +import type * as ServerSettingsModule from "../serverSettings.ts"; + +export interface ProviderSnapshotSettings { + readonly provider: Settings; + readonly enableProviderUpdateChecks: boolean; +} + +export function makeProviderSnapshotSettings( + provider: Settings, + settings: ServerSettings, +): ProviderSnapshotSettings { + return { + provider, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }; +} + +export function haveProviderSnapshotSettingsChanged( + previous: ProviderSnapshotSettings, + next: ProviderSnapshotSettings, +): boolean { + return !Equal.equals(previous, next); +} + +export function makeProviderSnapshotSettingsSource( + provider: Settings, + serverSettings: ServerSettingsModule.ServerSettingsService["Service"], +): { + readonly getSettings: Effect.Effect, ServerSettingsError>; + readonly streamSettings: Stream.Stream>; +} { + const mapSettings = (settings: ServerSettings) => + makeProviderSnapshotSettings(provider, settings); + return { + getSettings: serverSettings.getSettings.pipe(Effect.map(mapSettings)), + streamSettings: serverSettings.streamChanges.pipe(Stream.map(mapSettings)), + }; +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9e0ad364d97..40ed694723d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -29,7 +29,7 @@ import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -64,17 +64,18 @@ function makeMemorySecretStore() { const values = new Map(); const store = { get: ((name) => - Effect.sync( - () => values.get(name) ?? null, - )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + Effect.sync(() => { + const value = values.get(name); + return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], set: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], create: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], getOrCreateRandom: ((name, bytes) => Effect.sync(() => { const existing = values.get(name); @@ -84,12 +85,12 @@ function makeMemorySecretStore() { const generated = new Uint8Array(bytes); values.set(name, generated); return generated; - })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], remove: ((name) => Effect.sync(() => { values.delete(name); - })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], - } satisfies ServerSecretStore.ServerSecretStoreShape; + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], + } satisfies ServerSecretStore.ServerSecretStore["Service"]; return { store, setString: (name: string, value: string) => store.set(name, encodeSecret(value)), @@ -97,6 +98,27 @@ function makeMemorySecretStore() { } describe.sequential("signRelayAgentActivityPublishProof", () => { + it("distinguishes pending link credentials from disabled publication", () => { + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: false, + publishEnabled: false, + }), + ).toBe("waiting-for-link"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: false, + }), + ).toBe("disabled"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: true, + }), + ).toBe("enabled"); + }); + it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { const threadId = "thread-aggregate-1" as ThreadId; const now = "2026-05-25T00:00:00.000Z"; @@ -472,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), }), @@ -604,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; @@ -616,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), }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d02c83d563e..4e036e3ea0e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -1,8 +1,3 @@ -import { - RelayApi, - type RelayAgentActivityPublishProofPayload, - type RelayAgentActivityState, -} from "@t3tools/contracts/relay"; import type { EnvironmentId, OrchestrationEvent, @@ -10,13 +5,18 @@ import type { OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; +import { + RelayApi, + type RelayAgentActivityPublishProofPayload, + type RelayAgentActivityState, +} from "@t3tools/contracts/relay"; import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { + normalizeRelayIssuer, RELAY_ACTIVITY_PUBLISH_TYP, signRelayJwt, - normalizeRelayIssuer, } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -28,31 +28,29 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { FetchHttpClient } from "effect/unstable/http"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, } from "../cloud/config.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; -import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; - -export interface AgentAwarenessRelayShape { - readonly publishThread: (threadId: ThreadId) => Effect.Effect; - readonly start: () => Effect.Effect; -} +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, - AgentAwarenessRelayShape + { + readonly publishThread: (threadId: ThreadId) => Effect.Effect; + readonly start: () => Effect.Effect; + } >()("t3/relay/AgentAwarenessRelay") {} export function eventThreadId(event: OrchestrationEvent): ThreadId | null { @@ -100,6 +98,16 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean return value === "true"; } +export function resolveAgentActivityPublishingStartupState(input: { + readonly relayConfigured: boolean; + readonly publishEnabled: boolean; +}): "waiting-for-link" | "disabled" | "enabled" { + if (!input.relayConfigured) { + return "waiting-for-link"; + } + return input.publishEnabled ? "enabled" : "disabled"; +} + const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; @@ -255,18 +263,24 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; - const serverEnvironment = yield* ServerEnvironment; - const snapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const crypto = yield* Crypto.Crypto; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const activeSnapshotPublishedRef = yield* Ref.make(false); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => - secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); + secrets + .get(name) + .pipe( + Effect.map((bytes) => + Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, + ), + ); const readRelayConfig = Effect.gen(function* () { const [url, issuer, environmentCredential] = yield* Effect.all([ @@ -304,7 +318,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", { + yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { threadId, }); return; @@ -401,7 +415,7 @@ const make = Effect.gen(function* () { }); }); - const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) => + const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( Effect.catchCause((cause) => { return Effect.logWarning("agent activity publish failed", { @@ -423,7 +437,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing"); + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); return false; } const environmentId = yield* serverEnvironment.getEnvironmentId; @@ -444,31 +458,55 @@ const make = Effect.gen(function* () { return true; }); - const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { - while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { - yield* Ref.set(activeSnapshotPublishedRef, true); - return; + const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => + Effect.gen(function* () { + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); + if (published) { + yield* Ref.set(activeSnapshotPublishedRef, true); + if (logEnabledWhenReady) { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { + relayUrl: relayConfig?.url, + }); + } + return; + } + yield* Effect.sleep("5 seconds"); } - yield* Effect.sleep("5 seconds"); - } - }); + }); const worker = yield* makeDrainableWorker(publishThread); - const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( + const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( function* () { - const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); - if (!relayConfig) { - yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing"); - } else { - yield* Effect.logInfo("agent activity publishing enabled", { - relayUrl: relayConfig.url, - }); + const [relayConfig, publishEnabled] = yield* Effect.all([ + readRelayConfig.pipe(Effect.orElseSucceed(() => null)), + readPublishAgentActivityEnabled.pipe(Effect.orElseSucceed(() => false)), + ]); + const startupState = resolveAgentActivityPublishingStartupState({ + relayConfigured: relayConfig !== null, + publishEnabled, + }); + switch (startupState) { + case "waiting-for-link": + yield* Effect.logInfo( + "agent activity publishing standby; waiting for T3 Connect link reconciliation", + ); + break; + case "disabled": + yield* Effect.logInfo("agent activity publishing disabled by T3 Connect configuration"); + break; + case "enabled": + yield* Effect.logInfo("agent activity publishing enabled", { + relayUrl: relayConfig?.url, + }); + break; } yield* Effect.forkScoped( - Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), + Effect.sleep("1 second").pipe( + Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), + ), ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { @@ -496,10 +534,10 @@ const make = Effect.gen(function* () { }, ); - return { + return AgentAwarenessRelay.of({ publishThread, start, - } satisfies AgentAwarenessRelayShape; + }); }); export const layer = Layer.effect(AgentAwarenessRelay, make); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index eb8758b1282..839eb73b2bb 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -73,4 +74,27 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("preserves unexpected path-resolution failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const invalidCwd = `${workspaceRoot}\0invalid`; + const detectCalls: Array<{ readonly cwd: string }> = []; + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: invalidCwd }).pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + if (error._tag !== "VcsRepositoryDetectionError") return; + assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); + assert.strictEqual(error.cwd, invalidCwd); + assert.match(error.detail, /Failed to resolve a path/); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.deepStrictEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 63f1d133213..db1dc5bc8d2 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -13,29 +13,44 @@ import { type ReviewDiffPreviewResult, } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -export interface ReviewServiceShape { - readonly getDiffPreview: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; -} - -export class ReviewService extends Context.Service()( - "t3/review/ReviewService", -) {} - -export const make = Effect.fn("makeReviewService")(function* () { - const config = yield* ServerConfig; +export class ReviewService extends Context.Service< + ReviewService, + { + readonly getDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + } +>()("t3/review/ReviewService") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; - const canonicalizePath = (value: string) => - fileSystem.realPath(path.resolve(value)).pipe(Effect.orElseSucceed(() => path.resolve(value))); + const canonicalizePath = (value: string) => { + const resolvedPath = path.resolve(value); + return fileSystem.realPath(resolvedPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(resolvedPath) + : Effect.fail( + new VcsRepositoryDetectionError({ + operation: "ReviewService.assertWorkspaceBoundCwd.canonicalizePath", + cwd: resolvedPath, + detail: "Failed to resolve a path while validating the review workspace.", + cause, + }), + ), + }), + ); + }; const isWithinRoot = (candidate: string, root: string) => { const relative = path.relative(root, candidate); @@ -62,7 +77,7 @@ export const make = Effect.fn("makeReviewService")(function* () { }); }); - const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn( + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { yield* assertWorkspaceBoundCwd(input.cwd); @@ -96,4 +111,4 @@ export const make = Effect.fn("makeReviewService")(function* () { }); }); -export const layer = Layer.effect(ReviewService, make()); +export const layer = Layer.effect(ReviewService, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 77a4dbde25f..32a7cc17944 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -14,7 +14,7 @@ import { GitCommandError, KeybindingRule, MessageId, - ExternalLauncherError, + ExternalLauncherCommandNotFoundError, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -69,59 +69,33 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -import type { ServerConfigShape } from "./config.ts"; -import { deriveServerPaths, ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { - CheckpointDiffQuery, - type CheckpointDiffQueryShape, -} from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; -import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "./orchestration/Services/OrchestrationEngine.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; -import { - ProjectionSnapshotQuery, - type ProjectionSnapshotQueryShape, -} from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; -import { - ProviderRegistry, - type ProviderRegistryShape, -} from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; -import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; -import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; -import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; -import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; -import { - BrowserTraceCollector, - type BrowserTraceCollectorShape, -} from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import { - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, - type ProjectSetupScriptRunnerShape, -} from "./project/Services/ProjectSetupScriptRunner.ts"; -import { - RepositoryIdentityResolver, - type RepositoryIdentityResolverShape, -} from "./project/Services/RepositoryIdentityResolver.ts"; -import { - ServerEnvironment, - type ServerEnvironmentShape, -} from "./environment/Services/ServerEnvironment.ts"; +import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -132,10 +106,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"; @@ -341,32 +312,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* () { @@ -374,8 +353,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, @@ -403,8 +382,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, @@ -502,21 +481,21 @@ const buildAppUnderTest = (options?: { const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ ...options?.layers?.gitVcsDriver, }); - const gitManagerLayer = Layer.mock(GitManager)({ + const gitManagerLayer = Layer.mock(GitManager.GitManager)({ ...options?.layers?.gitManager, }); const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( - Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspacePaths.layer), Layer.provideMerge(vcsDriverRegistryLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, workspaceEntriesLayer, - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), + WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), + ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -545,7 +524,7 @@ const buildAppUnderTest = (options?: { disableLogger: true, }).pipe( Layer.provide( - Layer.mock(Keybindings)({ + Layer.mock(Keybindings.Keybindings)({ loadConfigState: Effect.succeed({ keybindings: [], issues: [], @@ -555,7 +534,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry)({ + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), @@ -569,7 +548,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), @@ -658,13 +637,13 @@ const buildAppUnderTest = (options?: { ), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( - Layer.mock(ProjectSetupScriptRunner)({ + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), ...options?.layers?.projectSetupScriptRunner, }), ), Layer.provide( - Layer.mock(TerminalManager)({ + Layer.mock(TerminalManager.TerminalManager)({ ...options?.layers?.terminalManager, }), ), @@ -692,7 +671,7 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngineService)({ + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, @@ -700,7 +679,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProjectionSnapshotQuery)({ + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getShellSnapshot: () => @@ -729,7 +708,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(CheckpointDiffQuery)({ + Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({ getTurnDiff: () => Effect.succeed({ threadId: defaultThreadId, @@ -751,13 +730,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, @@ -765,7 +744,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerRuntimeStartup)({ + Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ awaitCommandReady: Effect.void, markHttpListening: Effect.void, enqueueCommand: (effect) => effect, @@ -773,22 +752,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, }), @@ -4451,27 +4430,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("routes websocket rpc projects.searchEntries errors", () => + it.effect("preserves structured workspace rpc failures", () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-", + }); + const outsideDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-errors-outside-", + }); + const outsideFile = path.join(outsideDir, "outside.txt"); + yield* fs.writeFileString(outsideFile, "outside\n"); + yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); + const resolvedOutsideFile = yield* fs.realPath(outsideFile); + yield* buildAppUnderTest(); + const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); + const missingBrowseParent = path.join(workspaceDir, "missing-browse"); + const sensitiveQuery = "authorization: Bearer secret-token"; const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( + const results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: "/definitely/not/a/real/workspace/path", - query: "needle", - limit: 10, + Effect.all({ + search: client[WS_METHODS.projectsSearchEntries]({ + cwd: invalidWorkspace, + query: sensitiveQuery, + limit: 10, + }).pipe(Effect.result), + list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( + Effect.result, + ), + read: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "linked-outside.txt", + }).pipe(Effect.result), + browse: client[WS_METHODS.filesystemBrowse]({ + cwd: workspaceDir, + partialPath: "./missing-browse/child", + }).pipe(Effect.result), }), - ).pipe(Effect.result), + ), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assertInclude( - result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", + if ( + results.search._tag !== "Failure" || + results.search.failure._tag !== "ProjectSearchEntriesError" + ) { + assert.fail("Expected a ProjectSearchEntriesError"); + } + const searchError = results.search.failure; + assert.equal( + searchError.message, + `Failed to search workspace entries in '${invalidWorkspace}'.`, ); + assert.equal(searchError.cwd, invalidWorkspace); + assert.equal(searchError.queryLength, sensitiveQuery.length); + assert.notProperty(searchError, "query"); + assert.notInclude(searchError.message, "Bearer"); + assert.notInclude(searchError.message, "secret-token"); + assert.equal(searchError.limit, 10); + assert.equal(searchError.failure, "workspace_root_not_found"); + assert.equal(searchError.normalizedCwd, invalidWorkspace); + assert.isDefined(searchError.cause); + + if ( + results.list._tag !== "Failure" || + results.list.failure._tag !== "ProjectListEntriesError" + ) { + assert.fail("Expected a ProjectListEntriesError"); + } + const listError = results.list.failure; + assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`); + assert.equal(listError.cwd, invalidWorkspace); + assert.equal(listError.failure, "workspace_root_not_found"); + assert.equal(listError.normalizedCwd, invalidWorkspace); + assert.isDefined(listError.cause); + + if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + const readError = results.read.failure; + assert.equal( + readError.message, + `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, + ); + assert.equal(readError.cwd, workspaceDir); + assert.equal(readError.relativePath, "linked-outside.txt"); + assert.equal(readError.failure, "resolved_path_outside_root"); + assert.equal(readError.resolvedPath, resolvedOutsideFile); + assert.isDefined(readError.cause); + + if ( + results.browse._tag !== "Failure" || + results.browse.failure._tag !== "FilesystemBrowseError" + ) { + assert.fail("Expected a FilesystemBrowseError"); + } + const browseError = results.browse.failure; + assert.equal( + browseError.message, + `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`, + ); + assert.equal(browseError.cwd, workspaceDir); + assert.equal(browseError.partialPath, "./missing-browse/child"); + assert.equal(browseError.failure, "read_directory_failed"); + assert.equal(browseError.parentPath, missingBrowseParent); + assert.isDefined(browseError.cause); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4552,12 +4618,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)), ); @@ -4591,8 +4664,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: { @@ -5918,6 +5992,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, @@ -5936,17 +6011,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", @@ -5959,6 +6058,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + fetchRemote, + resolveRemoteTrackingCommit, createWorktree, }, vcsStatusBroadcaster: { @@ -6010,6 +6111,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, runSetupScript: true, }, @@ -6031,10 +6133,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, @@ -6063,7 +6180,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", @@ -6072,8 +6189,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({ @@ -6157,7 +6285,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", @@ -6166,7 +6294,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", @@ -6276,7 +6408,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 42a692c5394..81d0013b20c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, @@ -16,32 +16,32 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; -import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; -import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; -import { KeybindingsLive } from "./keybindings.ts"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import * as Keybindings from "./keybindings.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; @@ -51,12 +51,12 @@ import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletion import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; -import { ServerSettingsLive } from "./serverSettings.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -67,9 +67,9 @@ 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"; @@ -101,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 { @@ -135,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, }); @@ -166,7 +166,7 @@ const ReactorLayerLive = Layer.empty.pipe( ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter @@ -195,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), @@ -232,13 +232,13 @@ 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 PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const TerminalLayerLive = TerminalManagerLive.pipe( +const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), ); @@ -248,21 +248,21 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); -const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); -const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), +const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), Layer.provide(WorkspaceEntriesLayerLive), ); const WorkspaceLayerLive = Layer.mergeAll( - WorkspacePathsLive, + WorkspacePaths.layer, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, ); -const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( - Layer.provide(WorkspacePathsLive), +const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( + Layer.provide(WorkspacePaths.layer), ); const AuthLayerLive = EnvironmentAuth.layer.pipe( @@ -292,7 +292,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), - Layer.provideMerge(KeybindingsLive), + Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` @@ -305,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(ProjectFaviconResolverLayerLive), - Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(ServerEnvironment.layer), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -332,13 +332,13 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(ProcessDiagnostics.layer), Layer.provideMerge(ProcessResourceMonitor.layer), Layer.provideMerge(TraceDiagnostics.layer), - Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), - Layer.provideMerge(ServerLifecycleEventsLive), + Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); -const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( +const RuntimeServicesLive = ServerRuntimeStartup.layer.pipe( Layer.provideMerge(RuntimeDependenciesLive), ); @@ -361,14 +361,14 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { yield* HttpServer.HttpServer; - const startup = yield* ServerRuntimeStartup; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; yield* startup.markHttpListening; }), ); diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 14fbba9e238..4f7b75fb4bd 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -4,13 +4,13 @@ import { assertTrue } from "@effect/vitest/utils"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; it.effect( "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", () => Effect.gen(function* () { - const lifecycleEvents = yield* ServerLifecycleEvents; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const environment = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -49,5 +49,5 @@ it.effect( const snapshot = yield* lifecycleEvents.snapshot; assert.equal(snapshot.sequence, 2); assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]); - }).pipe(Effect.provide(ServerLifecycleEventsLive)), + }).pipe(Effect.provide(ServerLifecycleEvents.layer)), ); diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 88661b1593a..855d03490ef 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,9 +1,9 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; type LifecycleEventInput = @@ -15,44 +15,41 @@ interface SnapshotState { readonly events: ReadonlyArray; } -export interface ServerLifecycleEventsShape { - readonly publish: (event: LifecycleEventInput) => Effect.Effect; - readonly snapshot: Effect.Effect; - readonly stream: Stream.Stream; -} - export class ServerLifecycleEvents extends Context.Service< ServerLifecycleEvents, - ServerLifecycleEventsShape + { + readonly publish: (event: LifecycleEventInput) => Effect.Effect; + readonly snapshot: Effect.Effect; + readonly stream: Stream.Stream; + } >()("t3/serverLifecycleEvents") {} -export const ServerLifecycleEventsLive = Layer.effect( - ServerLifecycleEvents, - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const state = yield* Ref.make({ - sequence: 0, - events: [], - }); +const make = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const state = yield* Ref.make({ + sequence: 0, + events: [], + }); + + return { + publish: (event) => + Ref.modify(state, (current) => { + const nextSequence = current.sequence + 1; + const nextEvent = { + ...event, + sequence: nextSequence, + } satisfies ServerLifecycleStreamEvent; + const nextEvents = + nextEvent.type === "welcome" + ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] + : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; + return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; + }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), + snapshot: Ref.get(state), + get stream() { + return Stream.fromPubSub(pubsub); + }, + } satisfies ServerLifecycleEvents["Service"]; +}); - return { - publish: (event) => - Ref.modify(state, (current) => { - const nextSequence = current.sequence + 1; - const nextEvent = { - ...event, - sequence: nextSequence, - } satisfies ServerLifecycleStreamEvent; - const nextEvents = - nextEvent.type === "welcome" - ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] - : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; - return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; - }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), - snapshot: Ref.get(state), - get stream() { - return Stream.fromPubSub(pubsub); - }, - } satisfies ServerLifecycleEventsShape; - }), -); +export const layer = Layer.effect(ServerLifecycleEvents, make); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 90eebe33820..e331f0cd4d6 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -10,24 +10,14 @@ import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { ServerConfig } from "./config.ts"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; -import { - getAutoBootstrapDefaultModelSelection, - launchStartupHeartbeat, - makeCommandGate, - resolveAutoBootstrapWelcomeTargets, - resolveWelcomeBase, - ServerRuntimeStartupError, -} from "./serverRuntimeStartup.ts"; +import * as ServerConfig from "./config.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { + assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -37,7 +27,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = Effect.scoped( Effect.gen(function* () { const executionCount = yield* Ref.make(0); - const commandGate = yield* makeCommandGate; + const commandGate = yield* ServerRuntimeStartup.makeCommandGate; const queuedCommandFiber = yield* commandGate .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1)) @@ -58,7 +48,7 @@ it.effect("enqueueCommand waits for readiness and then drains queued work", () = it.effect("enqueueCommand fails queued work when readiness fails", () => Effect.scoped( Effect.gen(function* () { - const commandGate = yield* makeCommandGate; + const commandGate = yield* ServerRuntimeStartup.makeCommandGate; const failure = yield* Deferred.make(); const queuedCommandFiber = yield* commandGate @@ -66,13 +56,16 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => .pipe(Effect.forkScoped); yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ - message: "startup failed", + new ServerRuntimeStartup.ServerRuntimeStartupError({ + mode: "web", + host: "127.0.0.1", + port: 3773, + cause: new Error("test startup failure"), }), ); const error = yield* Effect.flip(Fiber.join(queuedCommandFiber)); - assert.equal(error.message, "startup failed"); + assert.equal(error.message, "Server runtime startup failed before command readiness."); }), ), ); @@ -82,8 +75,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa Effect.gen(function* () { const releaseCounts = yield* Deferred.make(); - yield* launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery, { + yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -104,7 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), }), - Effect.provideService(AnalyticsService, { + Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, flush: Effect.void, }), @@ -115,8 +108,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { - const welcome = yield* resolveWelcomeBase.pipe( - Effect.provideService(ServerConfig, { + const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", } as never), ); @@ -134,12 +127,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); - const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -152,7 +145,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -166,14 +159,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -188,12 +181,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); - const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -208,14 +201,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -236,12 +229,12 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa }); const dispatchCalls = yield* Ref.make>([]); - const error = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(ServerConfig, { + const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, } as never), - Effect.provideService(ProjectionSnapshotQuery, { + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), @@ -256,14 +249,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), - Effect.provideService(OrchestrationEngineService, { + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, - } satisfies OrchestrationEngineShape), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, randomUUIDv4: Effect.fail(uuidError), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cde308ffe42..cbdf58c4d67 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; @@ -409,7 +416,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 +470,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.ts b/apps/server/src/serverRuntimeState.ts index 996f9a2bfc9..289bddcb8bb 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({ @@ -23,7 +23,7 @@ const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( ); const runtimeOriginForConfig = ( - config: Pick, + config: Pick, port: number, ): PersistedServerRuntimeState["origin"] => { const hostname = @@ -32,7 +32,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d24f2ee2826..87feee669ec 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -13,14 +13,16 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; 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(), { @@ -77,7 +79,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 +147,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 +185,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 +212,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 +243,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 +269,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 +302,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 +339,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 +384,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 +404,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 +424,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 +471,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const instanceId = ProviderInstanceId.make("codex_personal"); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0e126604b4a..a5fcdc30c02 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -26,25 +26,26 @@ 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"; @@ -108,59 +109,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; - - /** Await settings runtime readiness. */ - readonly ready: Effect.Effect; +export class ServerSettingsService extends Context.Service< + ServerSettingsService, + { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; - /** Read the current settings. */ - readonly getSettings: Effect.Effect; + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; - /** Patch settings and persist. Returns the new full settings object. */ - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => Effect.Effect; + /** Read the current settings. */ + readonly getSettings: Effect.Effect; - /** Stream of settings change events. */ - readonly streamChanges: Stream.Stream; -} + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; -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 +256,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; @@ -350,7 +352,7 @@ const makeServerSettings = Effect.gen(function* () { ); environment.push({ ...variable, - value: secret ? textDecoder.decode(secret) : "", + value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }); } providerInstances[instanceId] = { @@ -577,9 +579,7 @@ const makeServerSettings = Effect.gen(function* () { Stream.map(resolveTextGenerationProvider), ); }, - } satisfies ServerSettingsShape; + } satisfies ServerSettingsService["Service"]; }); -export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( - Layer.provide(ServerSecretStore.layer), -); +export const layer = Layer.effect(ServerSettingsService, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f3078fcd06c..52aedd1d760 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -17,7 +17,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)({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index e39ce9f0100..442cae68934 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -11,7 +11,12 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + formatAzureDevOpsJsonDecodeError, + type NormalizedAzureDevOpsPullRequestRecord, +} from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -35,67 +40,60 @@ export interface AzureDevOpsRepositoryCloneUrls { readonly sshUrl: string; } -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 AzureDevOpsCli extends Context.Service()( - "t3/sourceControl/AzureDevOpsCli", -) {} +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 errorText(error: VcsError | unknown): string { if (typeof error === "object" && error !== null) { @@ -239,10 +237,10 @@ function decodeAzureDevOpsJson( ); } -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", @@ -253,7 +251,7 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }) .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); - const executeJson = (input: Parameters[0]) => + const executeJson = (input: Parameters[0]) => execute({ ...input, args: [...input.args, "--only-show-errors", "--output", "json"], @@ -282,15 +280,13 @@ 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({ operation: "listPullRequests", - detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -316,13 +312,13 @@ 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({ operation: "getPullRequest", - detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -434,4 +430,4 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () { }); }); -export const layer = Layer.effect(AzureDevOpsCli, make()); +export const layer = Layer.effect(AzureDevOpsCli, make); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 4ba3777159b..f007ecf7985 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)), ); } @@ -48,8 +48,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("creates Azure DevOps PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..8cd5bd7522d 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -4,7 +4,13 @@ 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, @@ -18,28 +24,26 @@ function providerError( }); } -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 +60,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 +84,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({ @@ -142,4 +146,4 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index e93362b8423..e4a7649e74a 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( - () => Effect.void, - ), - fetchRemoteTrackingBranch: vi.fn( + fetchRemoteBranch: vi.fn( () => Effect.void, ), - setBranchUpstream: vi.fn( + fetchRemoteTrackingBranch: vi.fn< + GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] + >(() => 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,42 @@ 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.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.detail, "Failed to send the Bitbucket request."); + 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("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -549,6 +596,50 @@ 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.strictEqual(error.operation, "checkoutPullRequest"); + assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request."); + assert.strictEqual( + error.message, + "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", + ); + assert.strictEqual(error.cause, gitCause); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 632778eca24..9a678ab44dc 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -15,7 +15,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"; @@ -44,7 +49,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass; - 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(); @@ -299,9 +297,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,18 +338,6 @@ 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, response: HttpClientResponse.HttpClientResponse, @@ -375,7 +359,7 @@ function responseError( ); } -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; @@ -420,7 +404,14 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( - Effect.mapError((cause) => requestError(operation, cause)), + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + detail: "Failed to send the Bitbucket request.", + cause, + }), + ), Effect.flatMap((response) => decodeResponse(operation, schema, response)), ); @@ -511,7 +502,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,17 +590,13 @@ 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) => @@ -675,7 +662,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) => @@ -758,7 +745,7 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { ? cause : new BitbucketApiError({ operation: "checkoutPullRequest", - detail: cause instanceof Error ? cause.message : String(cause), + detail: "Failed to check out the Bitbucket pull request.", cause, }), ), @@ -766,4 +753,4 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); }); -export const layer = Layer.effect(BitbucketApi, make()); +export const layer = Layer.effect(BitbucketApi, make); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index 07a3d386a35..8530e163dc6 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)), ); } @@ -53,7 +53,8 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( 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 +80,9 @@ it.effect("lists Bitbucket PRs through provider-neutral input names", () => it.effect("creates Bitbucket PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f3fd502f7fb..6c1d67434bf 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -4,9 +4,9 @@ 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, @@ -20,9 +20,7 @@ function providerError( }); } -function toChangeRequest( - summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, -): ChangeRequest { +function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: "bitbucket", number: summary.number, @@ -44,7 +42,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({ @@ -112,9 +110,9 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () }); }); -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 +122,5 @@ export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscov installHint: "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", probeAuth: bitbucket.probeAuth, - } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; + } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..e0e781bd8b5 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -15,7 +15,7 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); -const mockRun = vi.fn(); +const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..836c7e1eb74 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -12,7 +12,11 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -44,57 +48,56 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } -export interface GitHubCliShape { - 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; -} - -export class GitHubCli extends Context.Service()( - "t3/sourceControl/GitHubCli", -) {} +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") {} function errorText(error: VcsError | unknown): string { if (typeof error === "object" && error !== null) { @@ -226,10 +229,10 @@ function decodeGitHubJson( ); } -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", @@ -262,13 +265,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)}`, + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -294,13 +297,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)}`, + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -372,4 +375,4 @@ export const make = Effect.fn("makeGitHubCli")(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make()); +export const layer = Layer.effect(GitHubCli, make); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 32fd1a91ce3..141672c91c5 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)), ); } @@ -139,7 +139,8 @@ it.effect("treats empty non-open change request listing output as no results", ( it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 41329b97f75..b84d2504f93 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -11,9 +11,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"; +import { + combinedAuthOutput, + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; const isSourceControlProviderError = Schema.is(SourceControlProviderError); function providerError( @@ -50,14 +56,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 +72,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 +82,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 +106,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 @@ -147,7 +149,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( @@ -212,4 +214,4 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index c075027151a..f7c3b3e4bf0 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( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index bd430d9d01a..c5fd7ee52f0 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -10,7 +10,11 @@ import type * as DateTime from "effect/DateTime"; import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, + formatGitLabJsonDecodeError, +} from "./gitLabMergeRequests.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -44,61 +48,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", -) {} +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; + } +>()("t3/sourceControl/GitLabCli") {} function isVcsProcessSpawnError(error: unknown): boolean { return ( @@ -259,10 +262,10 @@ 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 execute: GitLabCli["Service"]["execute"] = (input) => process .run({ operation: "GitLabCli.execute", @@ -294,13 +297,13 @@ 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({ operation: "listMergeRequests", - detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -318,13 +321,13 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }).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({ operation: "getMergeRequest", - detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, cause: decoded.failure, }), ); @@ -449,4 +452,4 @@ export const make = Effect.fn("makeGitLabCli")(function* () { }); }); -export const layer = Layer.effect(GitLabCli, make()); +export const layer = Layer.effect(GitLabCli, make); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 842cf4a17cf..3dc61e132f3 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)), ); } @@ -54,7 +54,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = 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 +80,8 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = null; + let createInput: Parameters[0] | null = + null; const provider = yield* makeProvider({ createMergeRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 77f41600e0f..d1aaf06309d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -5,7 +5,16 @@ 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( @@ -42,48 +51,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 +110,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({ @@ -167,4 +170,4 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index f65710c4c9c..9e4702af04c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -17,15 +17,15 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const sourceControlProviderRegistryTestLayer = (input: { - readonly bitbucket: Partial; - readonly process: Partial; + readonly bitbucket: Partial; + readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( - Layer.provide(NodeServices.layer), - ), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), @@ -88,10 +88,12 @@ it.effect("reports implemented tools separately from locally available executabl }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( @@ -215,10 +217,12 @@ Logged in to gitlab.com as gitlab-user }), ); }, - } satisfies Partial; + } satisfies Partial; const testLayer = SourceControlDiscovery.layer.pipe( Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-auth-discovery-", + }), ), Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), Layer.provide( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index eab46d23560..660f32283e0 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { @@ -57,91 +57,86 @@ const VCS_PROBES: ReadonlyArray = [ }, ]; -export interface SourceControlDiscoveryShape { - readonly discover: Effect.Effect; -} - export class SourceControlDiscovery extends Context.Service< SourceControlDiscovery, - SourceControlDiscoveryShape + { + readonly discover: Effect.Effect; + } >()("t3/sourceControl/SourceControlDiscovery") {} -export const layer = Layer.effect( - SourceControlDiscovery, - Effect.gen(function* () { - const config = yield* ServerConfig; - const process = yield* VcsProcess.VcsProcess; - const sourceControlProviders = - yield* SourceControlProviderRegistry.SourceControlProviderRegistry; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; - const probe = ( - input: DiscoveryProbe & { readonly kind: Kind }, - ): Effect.Effect> => { - const executable = input.executable; - const versionArgs = input.versionArgs; + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => { + const executable = input.executable; + const versionArgs = input.versionArgs; - if (!executable || !versionArgs) { - return Effect.succeed({ - kind: input.kind, - label: input.label, - implemented: input.implemented, - status: "missing" as const, - version: Option.none(), - installHint: input.installHint, - detail: Option.some(input.installHint), - } satisfies DiscoveryProbeResult); - } + if (!executable || !versionArgs) { + return Effect.succeed({ + kind: input.kind, + label: input.label, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.some(input.installHint), + } satisfies DiscoveryProbeResult); + } - return process - .run({ - operation: "source-control.discovery.probe", - command: executable, - args: versionArgs, - cwd: config.cwd, - timeoutMs: 5_000, - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ - kind: input.kind, - label: input.label, - executable, - implemented: input.implemented, - status: "available" as const, - version: Option.orElse( - SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), - () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), - ), - installHint: input.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ + return process + .run({ + operation: "source-control.discovery.probe", + command: executable, + args: versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ kind: input.kind, label: input.label, executable, implemented: input.implemented, - status: "missing" as const, - version: Option.none(), + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), installHint: input.installHint, - detail: SourceControlProviderDiscovery.detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); - }; - - return SourceControlDiscovery.of({ - discover: Effect.all({ - versionControlSystems: Effect.all( - VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, - { concurrency: "unbounded" }, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), ), - sourceControlProviders: sourceControlProviders.discover, - }), - }); - }), -); + ); + }; + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: sourceControlProviders.discover, + }), + }); +}); + +export const layer = Layer.effect(SourceControlDiscovery, make); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index f0602f03d14..c2959ef878e 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -49,54 +49,52 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } -export interface SourceControlProviderShape { - readonly kind: SourceControlProviderKind; - readonly listChangeRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly headSelector: string; - readonly state: ChangeRequestState | "all"; - readonly limit?: number; - }) => Effect.Effect, SourceControlProviderError>; - readonly getChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly createChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; - readonly baseRefName: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - export class SourceControlProvider extends Context.Service< SourceControlProvider, - SourceControlProviderShape + { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } >()("t3/sourceControl/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 856d6948e09..e3a6bd1fb20 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -158,7 +158,7 @@ function isCliRemoteRefinementSpec( function probeCli(input: { readonly spec: SourceControlCliDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { return input.process @@ -202,7 +202,7 @@ function probeCli(input: { export function probeSourceControlProvider(input: { readonly spec: SourceControlProviderDiscoverySpec; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { if (input.spec.type === "api") { @@ -270,7 +270,7 @@ export function probeSourceControlProvider(input: { export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( function* (input: { readonly specs: ReadonlyArray; - readonly process: VcsProcess.VcsProcessShape; + readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; readonly context: SourceControlProvider.SourceControlProviderContext | null; }): Effect.fn.Return { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 833956ecc7e..6cea2d9a496 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -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 +37,7 @@ function makeRegistry(input: { readonly name: string; readonly url: string; }>; - readonly process?: Partial; + readonly process?: Partial; }) { const driver = { listRemotes: () => @@ -53,10 +53,10 @@ 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), + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), resolve: () => Effect.succeed({ kind: "git", @@ -70,7 +70,7 @@ function makeRegistry(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }); @@ -79,7 +79,7 @@ function makeRegistry(input: { ...input.process, }); - return SourceControlProviderRegistry.make().pipe( + return SourceControlProviderRegistry.make.pipe( Effect.provide( Layer.mergeAll( registryLayer, @@ -88,9 +88,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)), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 08f794d1f5c..b1f1ea7aae7 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,36 +30,40 @@ 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 { +): SourceControlProvider.SourceControlProvider["Service"] { const unsupported = (operation: string) => Effect.fail( new SourceControlProviderError({ @@ -113,9 +121,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,11 +171,11 @@ 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")( @@ -180,7 +188,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); const context = selectProviderContext(remotes.remotes); - return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ + return yield* refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, @@ -198,7 +206,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 +224,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 +236,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 +266,4 @@ export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () ]); }); -export const layer = Layer.effect(SourceControlProviderRegistry, make()); +export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 811b55c70a3..c792480b7fc 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type 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 +20,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,8 +52,8 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { } function makeLayer(input: { - readonly provider?: SourceControlProvider.SourceControlProviderShape; - readonly git?: Partial; + readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; + readonly git?: Partial; }) { return SourceControlRepositoryService.layer.pipe( Layer.provide( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 106d300ec2d..ff88a4c3146 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -24,21 +24,19 @@ 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 + { + readonly lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Effect.Effect; + readonly cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; + readonly publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Effect.Effect; + } >()("t3/sourceControl/SourceControlRepositoryService") {} function detailFromUnknown(cause: unknown): string { @@ -116,7 +114,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; @@ -315,4 +313,4 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }); }); -export const layer = Layer.effect(SourceControlRepositoryService, make()); +export const layer = Layer.effect(SourceControlRepositoryService, make); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts similarity index 89% rename from apps/server/src/telemetry/Layers/AnalyticsService.test.ts rename to apps/server/src/telemetry/AnalyticsService.test.ts index 5aa47406d9b..d69bab32feb 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -8,10 +8,9 @@ import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { ServerConfig } from "../../config.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import { AnalyticsService } from "../Services/AnalyticsService.ts"; -import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; +import * as ServerConfig from "../config.ts"; +import { getTelemetryIdentifier } from "./Identify.ts"; +import * as AnalyticsService from "./AnalyticsService.ts"; interface RecordedBatchRequest { readonly path: string; @@ -40,11 +39,11 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-base-", }); - const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); + const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer)); const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ T3CODE_TELEMETRY_ENABLED: true, @@ -79,7 +78,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); const telemetryIdentifier = yield* getTelemetryIdentifier; assert.equal(telemetryIdentifier !== null, true); - const analytics = yield* AnalyticsService; + const analytics = yield* AnalyticsService.AnalyticsService; for (let index = 0; index < 45; index += 1) { yield* analytics.record("test.flush.drain", { index }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts similarity index 72% rename from apps/server/src/telemetry/Layers/AnalyticsService.ts rename to apps/server/src/telemetry/AnalyticsService.ts index 0d51d7c66b1..5fdc7bdeb19 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,25 +1,26 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * Anonymous PostHog telemetry service. * - * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * Persists an installation-scoped anonymous identifier, buffers events in + * memory, and flushes batches over Effect's HTTP client. * - * @module AnalyticsServiceLive + * @module AnalyticsService */ - import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { ServerConfig } from "../../config.ts"; -import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import packageJson from "../../../package.json" with { type: "json" }; +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { readonly event: string; @@ -42,10 +43,33 @@ const TelemetryEnvConfig = Config.all({ wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), }); -const makeAnalyticsService = Effect.gen(function* () { +export class AnalyticsService extends Context.Service< + AnalyticsService, + { + /** Record an anonymous event for best-effort buffered delivery. */ + readonly record: ( + event: string, + properties?: Readonly>, + ) => Effect.Effect; + + /** Flush all currently queued telemetry events. */ + readonly flush: Effect.Effect; + } +>()("t3/telemetry/AnalyticsService") { + /** No-op layer for callers that intentionally disable telemetry. */ + static readonly layerTest = Layer.succeed( + AnalyticsService, + AnalyticsService.of({ + record: () => Effect.void, + flush: Effect.void, + }), + ); +} + +export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; @@ -79,7 +103,7 @@ const makeAnalyticsService = Effect.gen(function* () { }), ); - const sendBatch = Effect.fn("sendBatch")(function* ( + const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { if (!telemetryConfig.enabled || !identifier) return; @@ -109,7 +133,7 @@ const makeAnalyticsService = Effect.gen(function* () { ); }); - const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { + const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { const batch = yield* Ref.modify(bufferRef, (current) => { if (current.length === 0) { @@ -134,7 +158,7 @@ const makeAnalyticsService = Effect.gen(function* () { } }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - const record: AnalyticsServiceShape["record"] = Effect.fn("record")( + const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { if (!telemetryConfig.enabled || !identifier) return; @@ -154,10 +178,9 @@ const makeAnalyticsService = Effect.gen(function* () { yield* Effect.addFinalizer(() => flush); - return { - record, - flush, - } satisfies AnalyticsServiceShape; + return AnalyticsService.of({ record, flush }); }); -export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService); +export const layer = Layer.effect(AnalyticsService, make); + +export const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts new file mode 100644 index 00000000000..ab151821789 --- /dev/null +++ b/apps/server/src/telemetry/Identify.test.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; + +import * as ServerConfig from "../config.ts"; +import * as Identify from "./Identify.ts"; + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const sha256 = (value: string) => + NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex"); + +const makeCaptureLogger = (logs: CapturedLog[]) => + Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + +const findIdentityLog = ( + logs: ReadonlyArray, + source: Identify.TelemetryIdentitySource, + errorTag: string, +) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); + +it("preserves exact telemetry identity causes without deriving messages from them", () => { + const decodeCause = new Error("private nested decode details"); + const decodeError = new Identify.TelemetryIdentityDecodeError({ + source: "codex", + filePath: "/tmp/auth.json", + cause: decodeCause, + }); + const readCause = new Error("private nested read details"); + const readError = new Identify.TelemetryIdentityReadError({ + source: "anonymous", + filePath: "/tmp/anonymous-id", + cause: readCause, + }); + + assert.strictEqual(decodeError.cause, decodeCause); + assert.strictEqual(readError.cause, readCause); + assert.notInclude(decodeError.message, decodeCause.message); + assert.notInclude(readError.message, readCause.message); +}); + +it.layer(NodeServices.layer)("telemetry identity", (it) => { + it.effect("uses the persisted anonymous id when provider identities are absent", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const anonymousId = "persisted-anonymous-id"; + + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome( + path.join(config.baseDir, "home"), + ); + + assert.equal(identifier, sha256(anonymousId)); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-anonymous-", + }), + ), + ), + ); + + it.effect("logs structured decode context and falls back from malformed Codex auth", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); + const anonymousId = "decode-fallback-anonymous-id"; + const privateAccessToken = "private-codex-access-token"; + + yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); + yield* fileSystem.writeFileString( + codexAuthPath, + `{"tokens":{"access_token":"${privateAccessToken}"}}`, + ); + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.equal(identifier, sha256(anonymousId)); + const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); + assert.isDefined(decodeLog); + assert.equal( + decodeLog?.message, + `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, + ); + + assert.equal(decodeLog?.annotations.filePath, codexAuthPath); + assert.equal(decodeLog?.annotations.causeKind, "schema"); + assert.notProperty(decodeLog?.annotations ?? {}, "cause"); + const errorStack = decodeLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to decode codex telemetry identity"); + const annotations = Object.values(decodeLog?.annotations ?? {}) + .map(String) + .join("\n"); + assert.notInclude(annotations, privateAccessToken); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-decode-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + + yield* fileSystem.makeDirectory(config.anonymousIdPath); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.isNull(identifier); + assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); + + const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); + assert.isDefined(readLog); + assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); + assert.equal(readLog?.annotations.causeKind, "platform"); + assert.notEqual(readLog?.annotations.platformReason, "NotFound"); + assert.notProperty(readLog?.annotations ?? {}, "cause"); + const errorStack = readLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to read anonymous telemetry identity"); + assert.isUndefined( + findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), + ); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-read-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 364273a9e1d..b6c3d0066df 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -3,10 +3,12 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ @@ -18,60 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({ userID: Schema.String, }); -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} +export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); +export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -const hash = (value: string) => +export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( + "TelemetryIdentityReadError", + { + source: TelemetryIdentitySource, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( + "TelemetryIdentityDecodeError", + { + source: Schema.Literals(["codex", "claude"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryAnonymousIdGenerationError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdGenerationError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`; + } +} + +export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdPersistenceError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityHashError extends Schema.TaggedErrorClass()( + "TelemetryIdentityHashError", + { + source: TelemetryIdentitySource, + algorithm: Schema.Literal("SHA-256"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`; + } +} + +type TelemetryIdentityError = + | TelemetryIdentityReadError + | TelemetryIdentityDecodeError + | TelemetryAnonymousIdGenerationError + | TelemetryAnonymousIdPersistenceError + | TelemetryIdentityHashError; + +const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); +const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); + +function isNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + +const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { + if (cause instanceof PlatformError.PlatformError) { + return { + causeKind: "platform", + platformReason: cause.reason._tag, + }; + } + if (cause instanceof Schema.SchemaError) { + return { causeKind: "schema" }; + } + return { causeKind: "other" }; +}; + +const logTelemetryIdentityError = (error: TelemetryIdentityError) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + source: error.source, + ...("filePath" in error ? { filePath: error.filePath } : {}), + ...getTelemetryIdentityCauseAnnotations(error.cause), + ...(error.stack === undefined ? {} : { errorStack: error.stack }), + }), + ); + +const readIdentityFile = ( + fileSystem: FileSystem.FileSystem, + source: TelemetryIdentitySource, + filePath: string, +) => + fileSystem.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + new TelemetryIdentityReadError({ + source, + filePath, + cause, + }), + ), + }), + ); + +const hash = (source: TelemetryIdentitySource, value: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))), Effect.map(Encoding.encodeHex), Effect.mapError( (cause) => - new IdentifyUserError({ - message: "Failed to hash identifier", + new TelemetryIdentityHashError({ + source, + algorithm: "SHA-256", cause, }), ), ); -const getCodexAccountId = Effect.gen(function* () { +const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); - const authJson = yield* Effect.flatMap( - fileSystem.readFileString(authJsonPath), - Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), + const authJsonPath = path.join(homeDirectory, ".codex", "auth.json"); + const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const authJson = yield* decodeCodexAuthJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "codex", + filePath: authJsonPath, + cause, + }), + ), ); - return authJson.tokens.account_id; + return Option.some(authJson.tokens.account_id); }); -const getClaudeUserId = Effect.gen(function* () { +const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), + const claudeJsonPath = path.join(homeDirectory, ".claude.json"); + const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const claudeJson = yield* decodeClaudeJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "claude", + filePath: claudeJsonPath, + cause, + }), + ), ); - return claudeJson.userID; + return Option.some(claudeJson.userID); }); const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const { anonymousIdPath } = yield* ServerConfig; - - const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( - Effect.catch(() => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.tap((randomId) => fileSystem.writeFileString(anonymousIdPath, randomId)), - ), + const { anonymousIdPath } = yield* ServerConfig.ServerConfig; + + const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath); + if (Option.isSome(existing)) { + return existing.value; + } + + const anonymousId = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.mapError( + (cause) => + new TelemetryAnonymousIdGenerationError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), + ), + ); + yield* fileSystem.writeFileString(anonymousIdPath, anonymousId).pipe( + Effect.mapError( + (cause) => + new TelemetryAnonymousIdPersistenceError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), ), ); @@ -84,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () { * 2. ~/.claude.json userID * 3. ~/.t3/telemetry/anonymous-id */ -export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } +export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( + function* (homeDirectory: string) { + const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(codexAccountId)) { + return yield* hash("codex", codexAccountId.value); + } - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } + const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(claudeUserId)) { + return yield* hash("claude", claudeUserId.value); + } - const anonymousId = yield* Effect.result(upsertAnonymousId); - if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); - } + const anonymousId = yield* upsertAnonymousId.pipe( + Effect.map(Option.some), + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdGenerationError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdPersistenceError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(anonymousId)) { + return yield* hash("anonymous", anonymousId.value); + } - return null; -}).pipe( - Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })), + return null; + }, + Effect.tapError(logTelemetryIdentityError), Effect.orElseSucceed(() => null), ); + +export const getTelemetryIdentifier = Effect.suspend(() => + getTelemetryIdentifierForHome(NodeOS.homedir()), +); diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts index a2717c790dc..879a1de7cdb 100644 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -1,35 +1,2 @@ -/** - * AnalyticsService - Anonymous telemetry capture contract. - * - * Provides a best-effort event API for runtime telemetry and a strict - * `captureImmediate` method for call sites that need explicit error handling. - * - * @module AnalyticsService - */ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; - -export interface AnalyticsServiceShape { - /** - * Capture an event immediately; returns typed failure when capture fails. - */ - readonly record: ( - event: string, - properties?: Readonly>, - ) => Effect.Effect; - - /** - * Flush queued telemetry. - */ - readonly flush: Effect.Effect; -} - -export class AnalyticsService extends Context.Service()( - "t3/telemetry/Services/AnalyticsService", -) { - static readonly layerTest = Layer.succeed(AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }); -} +// Compatibility shim for the intentionally excluded orchestration harness. +export { AnalyticsService } from "../AnalyticsService.ts"; diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts new file mode 100644 index 00000000000..e04a54e6d33 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -0,0 +1,44 @@ +import { assert, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as BunPtyAdapter from "./BunPtyAdapter.ts"; + +it("describes unavailable Bun PTY operations structurally", () => { + const error = new BunPtyAdapter.BunPtyOperationUnavailableError({ + operation: "resize", + pid: 42, + }); + + expect(error).toMatchObject({ + _tag: "BunPtyOperationUnavailableError", + operation: "resize", + pid: 42, + }); + expect(error.message).toBe("Bun PTY resize is unavailable for process 42."); +}); + +it.effect("reports unsupported platforms with a structured startup defect", () => + Effect.gen(function* () { + const exit = yield* BunPtyAdapter.make().pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + const error = Cause.squash(exit.cause); + assert.instanceOf(error, BunPtyAdapter.BunPtyUnsupportedPlatformError); + expect(error).toMatchObject({ + _tag: "BunPtyUnsupportedPlatformError", + platform: "win32", + }); + expect(error.message).toBe( + "Bun PTY terminal support is unavailable on win32. Please use Node.js (e.g. by running `npx t3`) instead.", + ); + } + }), +); diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/BunPtyAdapter.ts similarity index 58% rename from apps/server/src/terminal/Layers/BunPTY.ts rename to apps/server/src/terminal/BunPtyAdapter.ts index 82ea1dcb9b9..88b68940de1 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -2,13 +2,37 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; -class BunPtyProcess implements PtyProcess { +import * as PtyAdapter from "./PtyAdapter.ts"; + +export class BunPtyUnsupportedPlatformError extends Schema.TaggedErrorClass()( + "BunPtyUnsupportedPlatformError", + { + platform: Schema.Literal("win32"), + }, +) { + override get message(): string { + return `Bun PTY terminal support is unavailable on ${this.platform}. Please use Node.js (e.g. by running \`npx t3\`) instead.`; + } +} + +export class BunPtyOperationUnavailableError extends Schema.TaggedErrorClass()( + "BunPtyOperationUnavailableError", + { + operation: Schema.Literals(["write", "resize"]), + pid: Schema.Number, + }, +) { + override get message(): string { + return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`; + } +} + +class BunPtyProcess implements PtyAdapter.PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); private readonly process: Bun.Subprocess; private didExit = false; @@ -33,14 +57,14 @@ class BunPtyProcess implements PtyProcess { write(data: string): void { if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid }); } this.process.terminal.write(data); } resize(cols: number, rows: number): void { if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid }); } this.process.terminal.resize(cols, rows); } @@ -60,7 +84,7 @@ class BunPtyProcess implements PtyProcess { }; } - onExit(callback: (event: PtyExitEvent) => void): () => void { + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -76,7 +100,7 @@ class BunPtyProcess implements PtyProcess { } } - private emitExit(event: PtyExitEvent): void { + private emitExit(event: PtyAdapter.PtyExitEvent): void { if (this.didExit) return; this.didExit = true; @@ -93,18 +117,15 @@ class BunPtyProcess implements PtyProcess { } } -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "win32") { - return yield* Effect.die( - "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", - ); - } - return { - spawn: (input) => - Effect.sync(() => { +export const make = Effect.fn("BunPtyAdapter.make")(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { + return yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform })); + } + return PtyAdapter.PtyAdapter.of({ + spawn: (input) => + Effect.try({ + try: () => { let processHandle: BunPtyProcess | null = null; const command = [input.shell, ...(input.args ?? [])]; const subprocess = Bun.spawn(command, { @@ -120,7 +141,15 @@ export const layer = Layer.effect( }); processHandle = new BunPtyProcess(subprocess); return processHandle; - }), - } satisfies PtyAdapterShape; - }), -); + }, + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "bun", + shell: input.shell, + cause, + }), + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts deleted file mode 100644 index e33d9b4b290..00000000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,2484 +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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as Equal from "effect/Equal"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import { ServerConfig } from "../../config.ts"; -import { - increment, - terminalRestartsTotal, - terminalSessionsTotal, -} from "../../observability/Metrics.ts"; -import * as ProcessRunner from "../../processRunner.ts"; -import * as PortScanner from "../../preview/PortScanner.ts"; -import { - TerminalCwdError, - TerminalHistoryError, - TerminalManager, - TerminalNotRunningError, - TerminalSessionLookupError, - type TerminalManagerShape, -} from "../Services/Manager.ts"; -import { - PtyAdapter, - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_PERSIST_DEBOUNCE_MS = 40; -const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; -const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; -const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; -const DEFAULT_OPEN_COLS = 120; -const DEFAULT_OPEN_ROWS = 30; -const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const MAX_TERMINAL_LABEL_LENGTH = 128; - -class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( - "TerminalSubprocessCheckError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - terminalPid: Schema.Number, - command: Schema.Literals(["powershell", "pgrep", "ps"]), - }, -) {} - -class TerminalProcessSignalError extends Schema.TaggedErrorClass()( - "TerminalProcessSignalError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - signal: Schema.Literals(["SIGTERM", "SIGKILL"]), - }, -) {} - -interface TerminalSubprocessInspectResult { - readonly hasRunningSubprocess: boolean; - readonly childCommand: string | null; - readonly processIds: ReadonlyArray; -} - -interface TerminalSubprocessInspector { - ( - terminalPid: number, - ): Effect.Effect; -} - -interface ShellCandidate { - shell: string; - args?: string[]; -} - -interface TerminalStartInput { - threadId: string; - terminalId: string; - cwd: string; - worktreePath?: string | null; - cols: number; - rows: number; - env?: Record; -} - -interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - pendingProcessEvents: Array; - pendingProcessEventIndex: number; - processEventDrainRunning: boolean; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - eventSequence: number; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ - childCommandLabel: string | null; - runtimeEnv: Record | null; -} - -interface PersistHistoryRequest { - history: string; - immediate: boolean; -} - -type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; - -type DrainProcessEventAction = - | { type: "idle" } - | { - type: "output"; - threadId: string; - terminalId: string; - sequence: number; - history: string | null; - data: string; - } - | { - type: "exit"; - process: PtyProcess | null; - threadId: string; - terminalId: string; - sequence: number; - exitCode: number | null; - exitSignal: number | null; - }; - -interface TerminalManagerState { - sessions: Map; - killFibers: Map>; -} - -function truncateTerminalWireLabel(value: string): string { - if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; - return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); -} - -function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { - let trimmed = raw.trim(); - if (trimmed.length === 0) return null; - if ( - (trimmed.startsWith("[") && trimmed.endsWith("]")) || - (trimmed.startsWith("(") && trimmed.endsWith(")")) - ) { - trimmed = trimmed.slice(1, -1).trim(); - } - const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); - if (firstToken.length === 0) return null; - const separators = platform === "win32" ? /[\\/]/ : /\//; - const base = firstToken.split(separators).at(-1) ?? firstToken; - const withoutExe = - platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; - return withoutExe.length > 0 ? withoutExe : null; -} - -function terminalWireLabel(session: TerminalSessionState): string { - if (session.hasRunningSubprocess && session.childCommandLabel) { - const trimmed = session.childCommandLabel.trim(); - if (trimmed.length > 0) { - return truncateTerminalWireLabel(trimmed); - } - } - return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); -} - -function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - worktreePath: session.worktreePath, - status: session.status, - pid: session.pid, - history: session.history, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - label: terminalWireLabel(session), - updatedAt: session.updatedAt, - sequence: session.eventSequence, - }; -} - -function summary(session: TerminalSessionState): TerminalSummary { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - worktreePath: session.worktreePath, - status: session.status, - pid: session.pid, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - hasRunningSubprocess: session.hasRunningSubprocess, - label: terminalWireLabel(session), - updatedAt: session.updatedAt, - }; -} - -function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { - switch (event.type) { - case "started": - case "restarted": - case "exited": - case "closed": - case "error": - case "activity": - return true; - case "output": - case "cleared": - return false; - } -} - -function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { - switch (event.type) { - case "started": - return { - type: "snapshot", - snapshot: event.snapshot, - }; - case "output": - case "exited": - case "closed": - case "error": - case "cleared": - case "restarted": - case "activity": - return event; - } -} - -function isDuplicateAttachSnapshotEvent( - event: TerminalEvent, - initialSnapshot: TerminalSessionSnapshot, -) { - return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" - ? event.sequence <= initialSnapshot.sequence - : event.type === "started" && - event.snapshot.threadId === initialSnapshot.threadId && - event.snapshot.terminalId === initialSnapshot.terminalId && - event.snapshot.updatedAt <= initialSnapshot.updatedAt; -} - -function advanceEventSequence(session: TerminalSessionState): { - readonly updatedAt: string; - readonly sequence: number; -} { - const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); - session.eventSequence += 1; - session.updatedAt = updatedAt; - return { updatedAt, sequence: session.eventSequence }; -} - -function cleanupProcessHandles(session: TerminalSessionState): void { - session.unsubscribeData?.(); - session.unsubscribeData = null; - session.unsubscribeExit?.(); - session.unsubscribeExit = null; -} - -function enqueueProcessEvent( - session: TerminalSessionState, - expectedPid: number, - event: PendingProcessEvent, -): boolean { - if (!session.process || session.status !== "running" || session.pid !== expectedPid) { - return false; - } - - session.pendingProcessEvents.push(event); - if (session.processEventDrainRunning) { - return false; - } - - session.processEventDrainRunning = true; - return true; -} - -function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { - if (platform === "win32") { - return "pwsh.exe"; - } - return env.SHELL ?? "bash"; -} - -function normalizeShellCommand( - value: string | undefined, - platform: NodeJS.Platform, -): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - if (platform === "win32") { - return trimmed; - } - - const firstToken = trimmed.split(/\s+/g)[0]?.trim(); - if (!firstToken) return null; - return firstToken.replace(/^['"]|['"]$/g, ""); -} - -function basenameForPlatform(command: string, platform: NodeJS.Platform): string { - const normalized = - platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); - const parts = normalized - .split(platform === "win32" ? /\\+/ : /\/+/) - .filter((part) => part.length > 0); - return parts.at(-1) ?? normalized; -} - -function joinWindowsPath(...parts: ReadonlyArray): string { - return parts - .map((part, index) => { - if (index === 0) return part.replace(/[\\/]+$/g, ""); - return part.replace(/^[\\/]+|[\\/]+$/g, ""); - }) - .filter((part) => part.length > 0) - .join("\\"); -} - -function shellCandidateFromCommand( - command: string | null, - platform: NodeJS.Platform, -): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = basenameForPlatform(command, platform).toLowerCase(); - if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { - return { shell: command, args: ["-NoLogo"] }; - } - if (platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; -} - -function windowsSystemRoot(env: NodeJS.ProcessEnv): string { - return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; -} - -function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath( - windowsSystemRoot(env), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); -} - -function windowsCmdPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); -} - -function formatShellCandidate(candidate: ShellCandidate): string { - if (!candidate.args || candidate.args.length === 0) return candidate.shell; - return `${candidate.shell} ${candidate.args.join(" ")}`; -} - -function uniqueShellCandidates(candidates: Array): ShellCandidate[] { - const seen = new Set(); - const ordered: ShellCandidate[] = []; - for (const candidate of candidates) { - if (!candidate) continue; - const key = formatShellCandidate(candidate); - if (seen.has(key)) continue; - seen.add(key); - ordered.push(candidate); - } - return ordered; -} - -function resolveShellCandidates( - shellResolver: () => string, - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): ShellCandidate[] { - const requested = shellCandidateFromCommand( - normalizeShellCommand(shellResolver(), platform), - platform, - ); - - if (platform === "win32") { - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand("pwsh.exe", platform), - shellCandidateFromCommand(windowsPowerShellPath(env), platform), - shellCandidateFromCommand("powershell.exe", platform), - shellCandidateFromCommand(env.ComSpec ?? null, platform), - shellCandidateFromCommand(windowsCmdPath(env), platform), - shellCandidateFromCommand("cmd.exe", platform), - ]); - } - - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), - shellCandidateFromCommand("/bin/zsh", platform), - shellCandidateFromCommand("/bin/bash", platform), - shellCandidateFromCommand("/bin/sh", platform), - shellCandidateFromCommand("zsh", platform), - shellCandidateFromCommand("bash", platform), - shellCandidateFromCommand("sh", platform), - ]); -} - -function isRetryableShellSpawnError(error: PtySpawnError): boolean { - const queue: unknown[] = [error]; - const seen = new Set(); - const messages: string[] = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || seen.has(current)) { - continue; - } - seen.add(current); - - if (typeof current === "string") { - messages.push(current); - continue; - } - - if (current instanceof Error) { - messages.push(current.message); - if (current.cause) { - queue.push(current.cause); - } - continue; - } - - if (typeof current === "object") { - const value = current as { message?: unknown; cause?: unknown }; - if (typeof value.message === "string") { - messages.push(value.message); - } - if (value.cause) { - queue.push(value.cause); - } - } - } - - const message = messages.join(" ").toLowerCase(); - return ( - message.includes("posix_spawnp failed") || - message.includes("enoent") || - message.includes("not found") || - message.includes("file not found") || - message.includes("no such file") - ); -} - -function parseFirstChildPidFromPgrep(stdout: string): number | null { - for (const line of stdout.split(/\r?\n/g)) { - const n = Number.parseInt(line.trim(), 10); - if (Number.isInteger(n) && n > 0) { - return n; - } - } - return null; -} - -function windowsInspectSubprocess( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.Effect< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processNameById = new Map(); - const childrenByParent = new Map(); - for (const line of result.stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - processNameById.set(pid, nameRaw?.trim() ?? ""); - const children = childrenByParent.get(parentPid) ?? []; - children.push(pid); - childrenByParent.set(parentPid, children); - } - const directChildren = childrenByParent.get(terminalPid) ?? []; - const childPid = directChildren[0]; - if (childPid === undefined) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processIds = new Set([terminalPid]); - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const pid of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(pid)) continue; - processIds.add(pid); - pending.push(pid); - } - } - const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - } as const; - }), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect Windows terminal subprocesses.", - cause, - terminalPid, - command: "powershell", - }), - ), - ); -} - -const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.fn.Return< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with pgrep.", - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner - .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], - timeout: "1 second", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with ps.", - cause, - terminalPid, - command: "ps", - }), - ), - ); - - let childPid: number | null = null; - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); - } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - childPid = pid; - break; - } - } - } - - if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - - const runComm = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "comm="], - timeout: "1 second", - maxOutputBytes: 8_192, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - - const commResult = yield* Effect.exit(runComm); - let rawComm: string | null = null; - if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { - rawComm = commResult.value.stdout.trim(); - } - - if (!rawComm || rawComm.length === 0) { - const runArgs = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "args="], - timeout: "1 second", - maxOutputBytes: 16_384, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - const argsResult = yield* Effect.exit(runArgs); - if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { - const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; - rawComm = first.length > 0 ? first : null; - } - } - - const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } - } - } else { - processIds.add(childPid); - } - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - }; -}); - -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - if (platform === "win32") { - return yield* windowsInspectSubprocess(terminalPid, platform); - } - return yield* posixInspectSubprocess(terminalPid, platform); - }); -} - -function capHistory(history: string, maxLines: number): string { - if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); - } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; -} - -function isCsiFinalByte(codePoint: number): boolean { - return codePoint >= 0x40 && codePoint <= 0x7e; -} - -function shouldStripCsiSequence(body: string, finalByte: string): boolean { - if (finalByte === "n") { - return true; - } - if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { - return true; - } - if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { - return true; - } - return false; -} - -function shouldStripOscSequence(content: string): boolean { - return /^(10|11|12);(?:\?|rgb:)/.test(content); -} - -function stripStringTerminator(value: string): string { - if (value.endsWith("\u001b\\")) { - return value.slice(0, -2); - } - const lastCharacter = value.at(-1); - if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { - return value.slice(0, -1); - } - return value; -} - -function findStringTerminatorIndex(input: string, start: number): number | null { - for (let index = start; index < input.length; index += 1) { - const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { - return index + 1; - } - if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { - return index + 2; - } - } - return null; -} - -function isEscapeIntermediateByte(codePoint: number): boolean { - return codePoint >= 0x20 && codePoint <= 0x2f; -} - -function isEscapeFinalByte(codePoint: number): boolean { - return codePoint >= 0x30 && codePoint <= 0x7e; -} - -function findEscapeSequenceEndIndex(input: string, start: number): number | null { - let cursor = start; - while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { - cursor += 1; - } - if (cursor >= input.length) { - return null; - } - return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; -} - -function sanitizeTerminalHistoryChunk( - pendingControlSequence: string, - data: string, -): { visibleText: string; pendingControlSequence: string } { - const input = `${pendingControlSequence}${data}`; - let visibleText = ""; - let index = 0; - - const append = (value: string) => { - visibleText += value; - }; - - while (index < input.length) { - const codePoint = input.charCodeAt(index); - - if (codePoint === 0x1b) { - const nextCodePoint = input.charCodeAt(index + 1); - if (Number.isNaN(nextCodePoint)) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - - if (nextCodePoint === 0x5b) { - let cursor = index + 2; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if ( - nextCodePoint === 0x5d || - nextCodePoint === 0x50 || - nextCodePoint === 0x5e || - nextCodePoint === 0x5f - ) { - const terminatorIndex = findStringTerminatorIndex(input, index + 2); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); - if (escapeSequenceEndIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - append(input.slice(index, escapeSequenceEndIndex)); - index = escapeSequenceEndIndex; - continue; - } - - if (codePoint === 0x9b) { - let cursor = index + 1; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { - const terminatorIndex = findStringTerminatorIndex(input, index + 1); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - append(input[index] ?? ""); - index += 1; - } - - return { visibleText, pendingControlSequence: "" }; -} - -function legacySafeThreadId(threadId: string): string { - return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); -} - -function toSafeThreadId(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}`; -} - -function toSafeTerminalId(terminalId: string): string { - return Encoding.encodeBase64Url(terminalId); -} - -function toSessionKey(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; -} - -function shouldExcludeTerminalEnvKey(key: string): boolean { - const normalizedKey = key.toUpperCase(); - if (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; - 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; -} - -const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { - const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; - const portDiscovery = yield* PortScanner.PortDiscovery; - return yield* makeTerminalManagerWithOptions({ - logsDir: terminalLogsDir, - ptyAdapter, - registerTerminalProcesses: portDiscovery.registerTerminalProcesses, - unregisterTerminal: portDiscovery.unregisterTerminal, - }); -}); - -export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( - function* (options: TerminalManagerOptions) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const logsDir = options.logsDir; - const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = yield* HostProcessPlatform; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. - // `options.env` is the test seam. - const baseEnv = options.env ?? process.env; - const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); - const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessInspector = - options.subprocessInspector ?? - ((terminalPid) => - defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); - const subprocessPollIntervalMs = - options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; - const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; - const maxRetainedInactiveSessions = - options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; - const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); - const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); - - yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); - - const managerStateRef = yield* SynchronizedRef.make({ - sessions: new Map(), - killFibers: new Map(), - }); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); - const workerScope = yield* Scope.make("sequential"); - yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { - for (const listener of terminalEventListeners) { - yield* listener(event).pipe(Effect.ignoreCause({ log: true })); - } - }); - - const historyPath = (threadId: string, terminalId: string) => { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); - } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); - }; - - const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - - const readManagerState = SynchronizedRef.get(managerStateRef); - - const modifyManagerState = ( - f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], - ) => SynchronizedRef.modify(managerStateRef, f); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = ( - threadId: string, - effect: Effect.Effect, - ): Effect.Effect => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( - process: PtyProcess | null, - ) { - if (!process) return; - const fiber: Option.Option> = yield* modifyManagerState< - Option.Option> - >((state) => { - const existing: Option.Option> = Option.fromNullishOr( - state.killFibers.get(process), - ); - if (Option.isNone(existing)) { - return [Option.none>(), state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [existing, { ...state, killFibers }] as const; - }); - if (Option.isSome(fiber)) { - yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); - } - }); - - const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( - process: PtyProcess, - fiber: Fiber.Fiber, - ) { - yield* modifyManagerState((state) => { - const killFibers = new Map(state.killFibers); - killFibers.set(process, fiber); - return [undefined, { ...state, killFibers }] as const; - }); - }); - - const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGTERM to terminal process.", - cause, - signal: "SIGTERM", - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - error: error.message, - }).pipe(Effect.as(false)), - ), - ); - if (!terminated) { - return; - } - - yield* Effect.sleep(processKillGraceMs); - - yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGKILL to terminal process.", - cause, - signal: "SIGKILL", - }), - }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - error: error.message, - }), - ), - ); - }); - - const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( - Effect.ensuring( - modifyManagerState((state) => { - if (!state.killFibers.has(process)) { - return [undefined, state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [undefined, { ...state, killFibers }] as const; - }), - ), - Effect.forkIn(workerScope), - ); - - yield* registerKillFiber(process, fiber); - }); - - const persistWorker = yield* makeKeyedCoalescingWorker< - string, - PersistHistoryRequest, - never, - never - >({ - merge: (current, next) => ({ - history: next.history, - immediate: current.immediate || next.immediate, - }), - process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { - if (!request.immediate) { - yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); - } - - const [threadId, terminalId] = sessionKey.split("\u0000"); - if (!threadId || !terminalId) { - return; - } - - yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - }), - }); - - const queuePersist = Effect.fn("terminal.queuePersist")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: false, - }); - }); - - const flushPersist = Effect.fn("terminal.flushPersist")(function* ( - threadId: string, - terminalId: string, - ) { - yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); - }); - - const persistHistory = Effect.fn("terminal.persistHistory")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: true, - }); - yield* flushPersist(threadId, terminalId); - }); - - const readHistory = Effect.fn("terminal.readHistory")(function* ( - threadId: string, - terminalId: string, - ) { - const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) - ) { - const raw = yield* fileSystem - .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - if (capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); - } - return capped; - } - - if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) - ) { - return ""; - } - - const raw = yield* fileSystem - .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return capped; - }); - - const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( - threadId: string, - terminalId: string, - ) { - yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - if (terminalId === DEFAULT_TERMINAL_ID) { - yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - } - }); - - const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( - threadId: string, - ) { - const threadPrefix = `${toSafeThreadId(threadId)}_`; - const entries = yield* fileSystem - .readDirectory(logsDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as Array)); - yield* Effect.forEach( - entries.filter( - (name) => - name === `${toSafeThreadId(threadId)}.log` || - name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), - ), - (name) => - fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal histories for thread", { - threadId, - error, - }), - ), - ), - { discard: true }, - ); - }); - - const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { - const stats = yield* fileSystem.stat(cwd).pipe( - Effect.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), - ); - if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); - } - }); - - const getSession = Effect.fn("terminal.getSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return> { - return yield* Effect.map(readManagerState, (state) => - Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), - ); - }); - - const requireSession = Effect.fn("terminal.requireSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return { - return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), - ); - }); - - const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { - return yield* readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].filter((session) => session.threadId === threadId), - ), - ); - }); - - const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( - function* () { - yield* modifyManagerState((state) => { - const inactiveSessions = [...state.sessions.values()].filter( - (session) => session.status !== "running", - ); - if (inactiveSessions.length <= maxRetainedInactiveSessions) { - return [undefined, state] as const; - } - - inactiveSessions.sort( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ); - - const sessions = new Map(state.sessions); - - const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; - for (const session of inactiveSessions.slice(0, toEvict)) { - const key = toSessionKey(session.threadId, session.terminalId); - sessions.delete(key); - } - - return [undefined, { ...state, sessions }] as const; - }); - }, - ); - - const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( - session: TerminalSessionState, - expectedPid: number, - ) { - while (true) { - const action: DrainProcessEventAction = yield* Effect.sync(() => { - if (session.pid !== expectedPid || !session.process || session.status !== "running") { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; - if (!nextEvent) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - session.pendingProcessEventIndex += 1; - if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - } - - if (nextEvent.type === "output") { - const sanitized = sanitizeTerminalHistoryChunk( - session.pendingHistoryControlSequence, - nextEvent.data, - ); - session.pendingHistoryControlSequence = sanitized.pendingControlSequence; - if (sanitized.visibleText.length > 0) { - session.history = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); - } - const eventStamp = advanceEventSequence(session); - - return { - type: "output", - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - history: sanitized.visibleText.length > 0 ? session.history : null, - data: nextEvent.data, - } as const; - } - - const process = session.process; - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.exitCode = Number.isInteger(nextEvent.event.exitCode) - ? nextEvent.event.exitCode - : null; - session.exitSignal = Number.isInteger(nextEvent.event.signal) - ? nextEvent.event.signal - : null; - const eventStamp = advanceEventSequence(session); - - return { - type: "exit", - process, - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - } as const; - }); - - if (action.type === "idle") { - return; - } - - if (action.type === "output") { - if (action.history !== null) { - yield* queuePersist(action.threadId, action.terminalId, action.history); - } - - yield* publishEvent({ - type: "output", - threadId: action.threadId, - terminalId: action.terminalId, - sequence: action.sequence, - data: action.data, - }); - continue; - } - - yield* clearKillFiber(action.process); - yield* unregisterTerminal({ - threadId: action.threadId, - terminalId: action.terminalId, - }); - yield* publishEvent({ - type: "exited", - threadId: action.threadId, - terminalId: action.terminalId, - sequence: action.sequence, - exitCode: action.exitCode, - exitSignal: action.exitSignal, - }); - yield* evictInactiveSessionsIfNeeded(); - return; - } - }); - - const stopProcess = Effect.fn("terminal.stopProcess")(function* ( - session: TerminalSessionState, - ) { - const process = session.process; - if (!process) return; - - const updatedAt = yield* nowIso; - yield* modifyManagerState((state) => { - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - return [undefined, state] as const; - }); - - yield* clearKillFiber(process); - yield* unregisterTerminal({ - threadId: session.threadId, - terminalId: session.terminalId, - }); - yield* startKillEscalation(process, session.threadId, session.terminalId); - yield* evictInactiveSessionsIfNeeded(); - }); - - const trySpawn = Effect.fn("terminal.trySpawn")(function* ( - shellCandidates: ReadonlyArray, - spawnEnv: NodeJS.ProcessEnv, - session: TerminalSessionState, - index = 0, - lastError: PtySpawnError | null = null, - ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> { - if (index >= shellCandidates.length) { - const detail = lastError?.message ?? "Failed to spawn PTY process"; - const tried = - shellCandidates.length > 0 - ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` - : ""; - return yield* new PtySpawnError({ - adapter: "terminal-manager", - message: `${detail}.${tried}`.trim(), - ...(lastError ? { cause: lastError } : {}), - }); - } - - const candidate = shellCandidates[index]; - if (!candidate) { - return yield* ( - lastError ?? - new PtySpawnError({ - adapter: "terminal-manager", - message: "No shell candidate available for PTY spawn.", - }) - ); - } - - const attempt = yield* Effect.result( - options.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.args } : {}), - cwd: session.cwd, - cols: session.cols, - rows: session.rows, - env: spawnEnv, - }), - ); - - if (attempt._tag === "Success") { - return { - process: attempt.success, - shellLabel: formatShellCandidate(candidate), - }; - } - - const spawnError = attempt.failure; - if (!isRetryableShellSpawnError(spawnError)) { - return yield* spawnError; - } - - return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); - }); - - const startSession = Effect.fn("terminal.startSession")(function* ( - session: TerminalSessionState, - input: TerminalStartInput, - eventType: "started" | "restarted", - ) { - yield* stopProcess(session); - yield* Effect.annotateCurrentSpan({ - "terminal.thread_id": session.threadId, - "terminal.id": session.terminalId, - "terminal.event_type": eventType, - "terminal.cwd": input.cwd, - }); - - const startingAt = yield* nowIso; - yield* modifyManagerState((state) => { - session.status = "starting"; - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.cols = input.cols; - session.rows = input.rows; - session.exitCode = null; - session.exitSignal = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = startingAt; - return [undefined, state] as const; - }); - - let ptyProcess: PtyProcess | null = null; - let startedShell: string | null = null; - - const startResult = yield* Effect.result( - increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( - Effect.andThen( - Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); - ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; - - const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - - let eventStamp: ReturnType = { - updatedAt: session.updatedAt, - sequence: session.eventSequence, - }; - yield* modifyManagerState((state) => { - session.process = ptyProcess; - session.pid = processPid; - session.status = "running"; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; - eventStamp = advanceEventSequence(session); - return [undefined, state] as const; - }); - - yield* publishEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - sequence: eventStamp.sequence, - snapshot: snapshot(session), - }); - }), - ), - ), - ); - - if (startResult._tag === "Success") { - return; - } - - { - const error = startResult.failure; - if (ptyProcess) { - yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); - } - - yield* modifyManagerState((state) => { - session.status = "error"; - session.pid = null; - session.process = null; - session.unsubscribeData = null; - session.unsubscribeExit = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - advanceEventSequence(session); - return [undefined, state] as const; - }); - yield* unregisterTerminal({ - threadId: session.threadId, - terminalId: session.terminalId, - }); - - yield* evictInactiveSessionsIfNeeded(); - - const message = error.message; - yield* publishEvent({ - type: "error", - threadId: session.threadId, - terminalId: session.terminalId, - sequence: session.eventSequence, - message, - }); - yield* Effect.logError("failed to start terminal", { - threadId: session.threadId, - terminalId: session.terminalId, - error: message, - ...(startedShell ? { shell: startedShell } : {}), - }); - } - }); - - const closeSession = Effect.fn("terminal.closeSession")(function* ( - threadId: string, - terminalId: string, - deleteHistoryOnClose: boolean, - ) { - const key = toSessionKey(threadId, terminalId); - const session = yield* getSession(threadId, terminalId); - const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; - - if (Option.isSome(session)) { - yield* stopProcess(session.value); - yield* unregisterTerminal({ threadId, terminalId }); - yield* persistHistory(threadId, terminalId, session.value.history); - } - - yield* flushPersist(threadId, terminalId); - - const removed = yield* modifyManagerState((state) => { - if (!state.sessions.has(key)) { - return [false, state] as const; - } - const sessions = new Map(state.sessions); - sessions.delete(key); - return [true, { ...state, sessions }] as const; - }); - - if (removed) { - yield* publishEvent({ - type: "closed", - threadId, - terminalId, - sequence: closedEventSequence, - }); - } - - if (deleteHistoryOnClose) { - yield* deleteHistory(threadId, terminalId); - } - }); - - const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { - const state = yield* readManagerState; - const runningSessions = [...state.sessions.values()].filter( - (session): session is TerminalSessionState & { pid: number } => - session.status === "running" && Number.isInteger(session.pid), - ); - - if (runningSessions.length === 0) { - return; - } - - const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( - session: TerminalSessionState & { pid: number }, - ) { - const terminalPid = session.pid; - const inspectResult = yield* subprocessInspector(terminalPid).pipe( - Effect.map(Option.some), - Effect.catch((reason) => - Effect.logWarning("failed to check terminal subprocess activity", { - threadId: session.threadId, - terminalId: session.terminalId, - terminalPid, - reason, - }).pipe(Effect.as(Option.none())), - ), - ); - - if (Option.isNone(inspectResult)) { - return; - } - - const next = inspectResult.value; - yield* registerTerminalProcesses({ - threadId: session.threadId, - terminalId: session.terminalId, - processIds: next.processIds, - }); - const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; - const event = yield* modifyManagerState((state) => { - const liveSession: Option.Option = Option.fromNullishOr( - state.sessions.get(toSessionKey(session.threadId, session.terminalId)), - ); - if ( - Option.isNone(liveSession) || - liveSession.value.status !== "running" || - liveSession.value.pid !== terminalPid || - (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && - liveSession.value.childCommandLabel === nextChildLabel) - ) { - return [Option.none(), state] as const; - } - - liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; - liveSession.value.childCommandLabel = nextChildLabel; - const eventStamp = advanceEventSequence(liveSession.value); - - return [ - Option.some({ - type: "activity" as const, - threadId: liveSession.value.threadId, - terminalId: liveSession.value.terminalId, - sequence: eventStamp.sequence, - hasRunningSubprocess: next.hasRunningSubprocess, - label: terminalWireLabel(liveSession.value), - }), - state, - ] as const; - }); - - if (Option.isSome(event)) { - yield* publishEvent(event.value); - } - }); - - yield* Effect.forEach(runningSessions, checkSubprocessActivity, { - concurrency: "unbounded", - discard: true, - }); - }); - - const hasRunningSessions = readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].some((session) => session.status === "running"), - ), - ); - - yield* Effect.forever( - hasRunningSessions.pipe( - Effect.flatMap((active) => - active - ? pollSubprocessActivity().pipe( - Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), - ) - : Effect.sleep(subprocessPollIntervalMs), - ), - ), - ).pipe(Effect.forkIn(workerScope)); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const sessions = yield* modifyManagerState( - (state) => - [ - [...state.sessions.values()], - { - ...state, - sessions: new Map(), - }, - ] as const, - ); - - const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( - session: TerminalSessionState, - ) { - cleanupProcessHandles(session); - if (!session.process) return; - yield* clearKillFiber(session.process); - yield* runKillEscalation(session.process, session.threadId, session.terminalId); - }); - - yield* Effect.forEach(sessions, cleanupSession, { - concurrency: "unbounded", - discard: true, - }); - }).pipe(Effect.ignoreCause({ log: true })), - ); - - const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const 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/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts similarity index 95% rename from apps/server/src/terminal/Layers/Manager.test.ts rename to apps/server/src/terminal/Manager.test.ts index 8b5aa3adbcd..c4c73ea7489 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -23,31 +23,24 @@ 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; 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) { @@ -74,7 +67,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); @@ -87,15 +80,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"; @@ -105,14 +98,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, }), ); @@ -123,9 +118,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, }), }); @@ -216,7 +211,7 @@ interface ManagerFixture { readonly baseDir: string; readonly logsDir: string; readonly ptyAdapter: FakePtyAdapter; - readonly manager: TerminalManagerShape; + readonly manager: TerminalManager.TerminalManager["Service"]; readonly getEvents: Effect.Effect>; } @@ -235,7 +230,7 @@ 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, @@ -319,6 +314,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(); @@ -478,6 +498,30 @@ it.layer( }), ); + it.effect("ignores delayed resize requests after a terminal closes", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + yield* manager.close({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + }); + + expect(process.resizeCalls).toEqual([]); + }), + ); + it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts new file mode 100644 index 00000000000..9fa9d07ebc9 --- /dev/null +++ b/apps/server/src/terminal/Manager.ts @@ -0,0 +1,2571 @@ +/** + * 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, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalSessionLookupError, + 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, + TerminalError, + TerminalHistoryError, + TerminalNotRunningError, + TerminalSessionLookupError, +}; + +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; +} + +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 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: 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", + 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({ + cause, + signal: "SIGKILL", + terminalPid: process.pid, + }), + }).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: 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(toTerminalHistoryError("read", threadId, terminalId))) + ) { + const raw = yield* fileSystem + .readFileString(nextPath) + .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); + const capped = capHistory(raw, historyLineLimit); + if (capped !== raw) { + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); + } + return capped; + } + + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + + const legacyPath = legacyHistoryPath(threadId); + if ( + !(yield* fileSystem + .exists(legacyPath) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) + ) { + return ""; + } + + const raw = yield* fileSystem + .readFileString(legacyPath) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + const capped = capHistory(raw, historyLineLimit); + yield* fileSystem + .writeFileString(nextPath, capped) + .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + yield* fileSystem.remove(legacyPath, { force: true }).pipe( + Effect.catch((cleanupError) => + Effect.logWarning("failed to remove legacy terminal history", { + threadId, + error: cleanupError, + }), + ), + ); + return capped; + }); + + const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( + threadId: string, + terminalId: string, + ) { + yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + if (terminalId === DEFAULT_TERMINAL_ID) { + yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal history", { + threadId, + terminalId, + error, + }), + ), + ); + } + }); + + const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( + threadId: string, + ) { + const threadPrefix = `${toSafeThreadId(threadId)}_`; + const entries = yield* fileSystem + .readDirectory(logsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + yield* Effect.forEach( + entries.filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ), + (name) => + fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to delete terminal histories for thread", { + threadId, + error, + }), + ), + ), + { discard: true }, + ); + }); + + const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { + const stats = yield* fileSystem.stat(cwd).pipe( + Effect.mapError( + (cause) => + new TerminalCwdError({ + cwd, + reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", + cause, + }), + ), + ); + if (stats.type !== "Directory") { + return yield* new TerminalCwdError({ + cwd, + reason: "notDirectory", + }); + } + }); + + const getSession = Effect.fn("terminal.getSession")(function* ( + threadId: string, + terminalId: string, + ): Effect.fn.Return> { + return yield* Effect.map(readManagerState, (state) => + Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), + ); + }); + + const requireSession = Effect.fn("terminal.requireSession")(function* ( + threadId: string, + terminalId: string, + ): Effect.fn.Return { + return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => + Option.match(session, { + onNone: () => + Effect.fail( + new TerminalSessionLookupError({ + threadId, + terminalId, + }), + ), + onSome: Effect.succeed, + }), + ); + }); + + const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { + return yield* readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()].filter((session) => session.threadId === threadId), + ), + ); + }); + + const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( + function* () { + yield* modifyManagerState((state) => { + const inactiveSessions = [...state.sessions.values()].filter( + (session) => session.status !== "running", + ); + if (inactiveSessions.length <= maxRetainedInactiveSessions) { + return [undefined, state] as const; + } + + inactiveSessions.sort( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || + left.threadId.localeCompare(right.threadId) || + left.terminalId.localeCompare(right.terminalId), + ); + + const sessions = new Map(state.sessions); + + const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; + for (const session of inactiveSessions.slice(0, toEvict)) { + const key = toSessionKey(session.threadId, session.terminalId); + sessions.delete(key); + } + + return [undefined, { ...state, sessions }] as const; + }); + }, + ); + + const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( + session: TerminalSessionState, + expectedPid: number, + ) { + while (true) { + const action: DrainProcessEventAction = yield* Effect.sync(() => { + if (session.pid !== expectedPid || !session.process || session.status !== "running") { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + return { type: "idle" } as const; + } + + const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; + if (!nextEvent) { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + return { type: "idle" } as const; + } + + session.pendingProcessEventIndex += 1; + if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + } + + if (nextEvent.type === "output") { + const sanitized = sanitizeTerminalHistoryChunk( + session.pendingHistoryControlSequence, + nextEvent.data, + ); + session.pendingHistoryControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length > 0) { + session.history = capHistory( + `${session.history}${sanitized.visibleText}`, + historyLineLimit, + ); + } + const eventStamp = advanceEventSequence(session); + + return { + type: "output", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + history: sanitized.visibleText.length > 0 ? session.history : null, + data: nextEvent.data, + } as const; + } + + const process = session.process; + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.exitCode = Number.isInteger(nextEvent.event.exitCode) + ? nextEvent.event.exitCode + : null; + session.exitSignal = Number.isInteger(nextEvent.event.signal) + ? nextEvent.event.signal + : null; + const eventStamp = advanceEventSequence(session); + + return { + type: "exit", + process, + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + } as const; + }); + + if (action.type === "idle") { + return; + } + + if (action.type === "output") { + if (action.history !== null) { + yield* queuePersist(action.threadId, action.terminalId, action.history); + } + + yield* publishEvent({ + type: "output", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.sequence, + data: action.data, + }); + continue; + } + + yield* clearKillFiber(action.process); + yield* unregisterTerminal({ + threadId: action.threadId, + terminalId: action.terminalId, + }); + yield* publishEvent({ + type: "exited", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.sequence, + exitCode: action.exitCode, + exitSignal: action.exitSignal, + }); + yield* evictInactiveSessionsIfNeeded(); + return; + } + }); + + const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) { + const process = session.process; + if (!process) return; + + const updatedAt = yield* nowIso; + yield* modifyManagerState((state) => { + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.updatedAt = updatedAt; + return [undefined, state] as const; + }); + + yield* clearKillFiber(process); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); + yield* startKillEscalation(process, session.threadId, session.terminalId); + yield* evictInactiveSessionsIfNeeded(); + }); + + const trySpawn = Effect.fn("terminal.trySpawn")(function* ( + shellCandidates: ReadonlyArray, + spawnEnv: NodeJS.ProcessEnv, + session: TerminalSessionState, + index = 0, + lastError: 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, + error: message, + ...(startedShell ? { shell: startedShell } : {}), + }); + } + }); + + const closeSession = Effect.fn("terminal.closeSession")(function* ( + threadId: string, + terminalId: string, + deleteHistoryOnClose: boolean, + ) { + const key = toSessionKey(threadId, terminalId); + const session = yield* getSession(threadId, terminalId); + const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; + + if (Option.isSome(session)) { + yield* stopProcess(session.value); + yield* unregisterTerminal({ threadId, terminalId }); + yield* persistHistory(threadId, terminalId, session.value.history); + } + + yield* flushPersist(threadId, terminalId); + + const removed = yield* modifyManagerState((state) => { + if (!state.sessions.has(key)) { + return [false, state] as const; + } + const sessions = new Map(state.sessions); + sessions.delete(key); + return [true, { ...state, sessions }] as const; + }); + + if (removed) { + yield* publishEvent({ + type: "closed", + threadId, + terminalId, + sequence: closedEventSequence, + }); + } + + if (deleteHistoryOnClose) { + yield* deleteHistory(threadId, terminalId); + } + }); + + const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { + const state = yield* readManagerState; + const runningSessions = [...state.sessions.values()].filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && Number.isInteger(session.pid), + ); + + if (runningSessions.length === 0) { + return; + } + + const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( + session: TerminalSessionState & { pid: number }, + ) { + const terminalPid = session.pid; + const inspectResult = yield* subprocessInspector(terminalPid).pipe( + Effect.map(Option.some), + Effect.catch((reason) => + Effect.logWarning("failed to check terminal subprocess activity", { + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid, + reason, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(inspectResult)) { + return; + } + + const next = inspectResult.value; + yield* registerTerminalProcesses({ + threadId: session.threadId, + terminalId: session.terminalId, + processIds: next.processIds, + }); + const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; + const event = yield* modifyManagerState((state) => { + const liveSession: Option.Option = Option.fromNullishOr( + state.sessions.get(toSessionKey(session.threadId, session.terminalId)), + ); + if ( + Option.isNone(liveSession) || + liveSession.value.status !== "running" || + liveSession.value.pid !== terminalPid || + (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && + liveSession.value.childCommandLabel === nextChildLabel) + ) { + return [Option.none(), state] as const; + } + + liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; + liveSession.value.childCommandLabel = nextChildLabel; + const eventStamp = advanceEventSequence(liveSession.value); + + return [ + Option.some({ + type: "activity" as const, + threadId: liveSession.value.threadId, + terminalId: liveSession.value.terminalId, + sequence: eventStamp.sequence, + hasRunningSubprocess: next.hasRunningSubprocess, + label: terminalWireLabel(liveSession.value), + }), + state, + ] as const; + }); + + if (Option.isSome(event)) { + yield* publishEvent(event.value); + } + }); + + yield* Effect.forEach(runningSessions, checkSubprocessActivity, { + concurrency: "unbounded", + discard: true, + }); + }); + + const hasRunningSessions = readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()].some((session) => session.status === "running"), + ), + ); + + yield* Effect.forever( + hasRunningSessions.pipe( + Effect.flatMap((active) => + active + ? pollSubprocessActivity().pipe( + Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), + ) + : Effect.sleep(subprocessPollIntervalMs), + ), + ), + ).pipe(Effect.forkIn(workerScope)); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const sessions = yield* modifyManagerState( + (state) => + [ + [...state.sessions.values()], + { + ...state, + sessions: new Map(), + }, + ] as const, + ); + + const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( + session: TerminalSessionState, + ) { + cleanupProcessHandles(session); + if (!session.process) return; + yield* clearKillFiber(session.process); + yield* runKillEscalation(session.process, session.threadId, session.terminalId); + }); + + yield* Effect.forEach(sessions, cleanupSession, { + concurrency: "unbounded", + discard: true, + }); + }).pipe(Effect.ignoreCause({ log: true })), + ); + + const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const 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: 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) + ) { + 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: 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.sync(() => process.write(input.data)); + }); + + 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; + } + session.value.cols = input.cols; + session.value.rows = input.rows; + session.value.updatedAt = yield* nowIso; + yield* Effect.sync(() => process.resize(input.cols, input.rows)); + }); + + const resize: TerminalManager["Service"]["resize"] = (input) => + withThreadLock(input.threadId, resizeLocked(input)); + + const clear: TerminalManager["Service"]["clear"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const terminalId = input.terminalId; + const session = yield* requireSession(input.threadId, terminalId); + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + const eventStamp = advanceEventSequence(session); + yield* persistHistory(input.threadId, terminalId, session.history); + yield* publishEvent({ + type: "cleared", + threadId: input.threadId, + terminalId, + sequence: eventStamp.sequence, + }); + }), + ); + + const restart: TerminalManager["Service"]["restart"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: "", + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }), + ); + + const close: TerminalManager["Service"]["close"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.terminalId) { + yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); + return; + } + + const threadSessions = yield* sessionsForThread(input.threadId); + yield* Effect.forEach( + threadSessions, + (session) => closeSession(input.threadId, session.terminalId, false), + { discard: true }, + ); + + if (input.deleteHistory) { + yield* deleteAllHistoryForThread(input.threadId); + } + }), + ); + + return TerminalManager.of({ + open, + attachStream, + write, + resize, + clear, + restart, + close, + subscribe, + subscribeMetadata, + }); +}); + +export const layer = Layer.effect(TerminalManager, make()).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts similarity index 53% rename from apps/server/src/terminal/Layers/NodePTY.test.ts rename to apps/server/src/terminal/NodePtyAdapter.test.ts index 46840214b66..ed87440d499 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -1,12 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { layer } from "./NodePTY.ts"; +import * as NodePtyAdapter from "./NodePtyAdapter.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; const spawn = vi.fn(() => ({ pid: 42, @@ -19,7 +21,7 @@ const spawn = vi.fn(() => ({ vi.mock("node-pty", () => ({ spawn })); -const testLayer = layer.pipe( +const testLayer = NodePtyAdapter.layer.pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -31,7 +33,7 @@ const testLayer = layer.pipe( it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { - const adapter = yield* PtyAdapter; + const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ shell: "powershell.exe", args: ["-NoLogo"], @@ -56,3 +58,31 @@ it.effect("spawns through the public adapter with the provided host references", ]); }).pipe(Effect.provide(testLayer)), ); + +it.effect("reports native module load failures as structured startup defects", () => + Effect.gen(function* () { + const cause = new Error("native binding could not be loaded"); + const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasDies(exit.cause)); + const error = Cause.squash(exit.cause); + assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError); + assert.deepInclude(error, { + _tag: "NodePtyModuleLoadError", + platform: "win32", + architecture: "x64", + }); + assert.equal(error.message, "Failed to load node-pty for win32-x64."); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/NodePtyAdapter.ts similarity index 50% rename from apps/server/src/terminal/Layers/NodePTY.ts rename to apps/server/src/terminal/NodePtyAdapter.ts index 2b19fe4ac51..ac06e1edfab 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,22 +1,33 @@ -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +export class NodePtyModuleLoadError extends Schema.TaggedErrorClass()( + "NodePtyModuleLoadError", + { + platform: Schema.String, + architecture: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load node-pty for ${this.platform}-${this.architecture}.`; + } +} + +type NodePtyModuleLoader = () => Promise; let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = createRequire(import.meta.url); + const requireForNodePty = NodeModule.createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; @@ -56,7 +67,7 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); -class NodePtyProcess implements PtyProcess { +class NodePtyProcess implements PtyAdapter.PtyProcess { private readonly process: import("node-pty").IPty; constructor(process: import("node-pty").IPty) { @@ -86,7 +97,7 @@ class NodePtyProcess implements PtyProcess { }; } - onExit(callback: (event: PtyExitEvent) => void): () => void { + onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { const disposable = this.process.onExit((event) => { callback({ exitCode: event.exitCode, @@ -99,47 +110,56 @@ class NodePtyProcess implements PtyProcess { } } -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const nodePty = yield* Effect.promise(() => import("node-pty")); - - const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( - ensureNodePtySpawnHelperExecutable().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, architecture), - Effect.orElseSucceed(() => undefined), - ), - ); - - return { - spawn: Effect.fn(function* (input) { - yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", - }), - catch: (cause) => - new PtySpawnError({ - adapter: "node-pty", - message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", - cause, - }), - }); - return new NodePtyProcess(ptyProcess); +export const make = Effect.fn("NodePtyAdapter.make")(function* ( + loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"), +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + + const nodePty = yield* Effect.tryPromise({ + try: loadNodePtyModule, + catch: (cause) => + new NodePtyModuleLoadError({ + platform, + architecture, + cause, }), - } satisfies PtyAdapterShape; - }), -); + }).pipe(Effect.orDie); + + const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( + ensureNodePtySpawnHelperExecutable().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), + Effect.orElseSucceed(() => undefined), + ), + ); + + return PtyAdapter.PtyAdapter.of({ + spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { + yield* ensureNodePtySpawnHelperExecutableCached; + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: input.shell, + cause, + }), + }); + return new NodePtyProcess(ptyProcess); + }), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts new file mode 100644 index 00000000000..f4ac9516537 --- /dev/null +++ b/apps/server/src/terminal/PtyAdapter.test.ts @@ -0,0 +1,34 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); + +describe("PtySpawnError", () => { + it("derives messages from structural context while preserving the full cause chain", () => { + const spawnCause = new Error("spawn /bin/zsh ENOENT"); + const adapterError = new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: "/bin/zsh", + cause: spawnCause, + }); + const managerError = new PtyAdapter.PtySpawnError({ + adapter: "terminal-manager", + attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], + cause: adapterError, + }); + + assert(isPtySpawnError(managerError)); + assert.strictEqual( + managerError.message, + "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", + ); + assert.strictEqual( + adapterError.message, + "Failed to spawn PTY process '/bin/zsh' with node-pty.", + ); + assert.strictEqual(managerError.cause, adapterError); + assert.strictEqual(adapterError.cause, spawnCause); + }); +}); diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/PtyAdapter.ts similarity index 57% rename from apps/server/src/terminal/Services/PTY.ts rename to apps/server/src/terminal/PtyAdapter.ts index 7af78810efa..67147035bb5 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -6,18 +6,28 @@ * * @module PtyAdapter */ +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; /** - * PtyError - Error type for PTY adapter operations. + * PtySpawnError - Error type for PTY spawn failures. */ export class PtySpawnError extends Schema.TaggedErrorClass()("PtySpawnError", { adapter: Schema.String, - message: Schema.String, + shell: Schema.optional(Schema.String), + attemptedShells: Schema.optional(Schema.Array(Schema.String)), cause: Schema.optional(Schema.Defect()), -}) {} +}) { + override get message(): string { + const shell = this.shell === undefined ? "" : ` '${this.shell}'`; + const attemptedShells = + this.attemptedShells === undefined || this.attemptedShells.length === 0 + ? "" + : ` Tried shells: ${this.attemptedShells.join(", ")}.`; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; + } +} export interface PtyExitEvent { exitCode: number; @@ -42,19 +52,15 @@ export interface PtySpawnInput { env: NodeJS.ProcessEnv; } -/** - * PtyAdapterShape - Service API for spawning and controlling PTY processes. - */ -export interface PtyAdapterShape { - /** - * Spawn a PTY process for a terminal session. - */ - spawn(input: PtySpawnInput): Effect.Effect; -} - /** * PtyAdapter - Service tag for PTY process integration. */ -export class PtyAdapter extends Context.Service()( - "t3/terminal/Services/PTY/PtyAdapter", -) {} +export class PtyAdapter extends Context.Service< + PtyAdapter, + { + /** + * Spawn a PTY process for a terminal session. + */ + readonly spawn: (input: PtySpawnInput) => Effect.Effect; + } +>()("t3/terminal/PtyAdapter") {} diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts deleted file mode 100644 index 51c66f49f7c..00000000000 --- a/apps/server/src/terminal/Services/Manager.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * TerminalManager - Terminal session orchestration service interface. - * - * Owns terminal lifecycle operations, output fanout, and session state - * transitions for thread-scoped terminals. - * - * @module TerminalManager - */ -import { - 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 0c53dbecea0..c8fe4ead3be 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -9,13 +9,13 @@ import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); @@ -79,7 +79,7 @@ function withFakeClaudeEnv( homeMustBe?: string; claudeConfig?: Partial; }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 91ad90b786e..872bf936cb1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -1,7 +1,7 @@ /** * ClaudeTextGeneration – Text generation layer using the Claude CLI. * - * Implements the same TextGenerationShape contract as CodexTextGeneration but + * Implements the same TextGeneration service contract as CodexTextGeneration but * delegates to the `claude` CLI (`claude -p`) with structured JSON output * instead of the `codex exec` CLI. * @@ -18,7 +18,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -260,107 +260,103 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }); // --------------------------------------------------------------------------- - // TextGenerationShape methods + // TextGeneration service methods // --------------------------------------------------------------------------- - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "ClaudeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("ClaudeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("ClaudeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "ClaudeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("ClaudeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "ClaudeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "ClaudeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runClaudeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runClaudeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + }; }); - return { - title: sanitizeThreadTitle(generated.title), - }; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index cf0ad7d5781..24054a95870 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -11,8 +11,8 @@ import { expect } from "vite-plus/test"; import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -21,7 +21,7 @@ const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( "gpt-5.4-mini", ); -const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); @@ -169,7 +169,7 @@ function withFakeCodexEnv( stdinMustContain?: string; stdinMustNotContain?: string; }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -427,7 +427,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const attachmentId = "thread-branch-image-attachment"; const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); yield* fs.makeDirectory(attachmentsDir, { recursive: true }); @@ -465,7 +465,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const attachmentId = "thread-1-attachment"; const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); yield* fs.makeDirectory(attachmentsDir, { recursive: true }); @@ -514,7 +514,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; const missingAttachmentId = "thread-missing-attachment"; const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 80b39af2584..95783b06cca 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -12,14 +12,10 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { - type BranchNameGenerationInput, - type ThreadTitleGenerationResult, - type TextGenerationShape, -} from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -50,7 +46,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); + const serverConfig = yield* Effect.service(ServerConfig.ServerConfig); const resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { @@ -121,7 +117,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generatePrContent" | "generateBranchName" | "generateThreadTitle", - attachments: BranchNameGenerationInput["attachments"], + attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { return { imagePaths: [] }; @@ -298,114 +294,110 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }).pipe(Effect.ensuring(cleanup)); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CodexTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - const generated = yield* runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CodexTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CodexTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CodexTextGeneration.generateBranchName", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CodexTextGeneration.generateThreadTitle", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); - const generated = yield* runCodexJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index c7ca9f7086e..2dc4720dcad 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -16,27 +16,27 @@ import { expect } from "vite-plus/test"; import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-cursor-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( + const binDir = NodePath.join(dir, "bin"); + const agentPath = NodePath.join(binDir, "agent"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( agentPath, [ "#!/bin/sh", @@ -50,19 +50,19 @@ function makeAcpAgentWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(agentPath, 0o755); + NodeFS.chmodSync(agentPath, 0o755); return agentPath; } function withFakeAcpAgent( env: Record, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-acp-")); yield* Effect.addFinalizer(() => Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }), ); const agentPath = makeAcpAgentWrapper(tempDir, env); @@ -76,7 +76,7 @@ function waitForFileContent(path: string): Effect.Effect { return Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 5_000; for (;;) { - const result = yield* Effect.exit(Effect.sync(() => readFileSync(path, "utf8"))); + const result = yield* Effect.exit(Effect.sync(() => NodeFS.readFileSync(path, "utf8"))); if (Exit.isSuccess(result)) { return result.value; } @@ -92,8 +92,10 @@ function waitForFileContent(path: string): Effect.Effect { it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { it.effect("uses ACP model config options instead of raw CLI model ids", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpAgent( { @@ -123,7 +125,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { expect(generated.subject).toBe("Add generated commit message"); expect(generated.body).toBe("- verify cursor acp model config path"); - const requests = readFileSync(requestLogPath, "utf8") + const requests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -181,7 +183,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { ]), ); - rmSync(requestLogDir, { recursive: true, force: true }); + NodeFS.rmSync(requestLogDir, { recursive: true, force: true }); }), ); }); @@ -235,8 +237,10 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { ); it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); - const exitLogPath = path.join(exitLogDir, "exit.log"); + const exitLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-cursor-text-exit-log-"), + ); + const exitLogPath = NodePath.join(exitLogDir, "exit.log"); return withFakeAcpAgent( { @@ -265,7 +269,7 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { const exitLog = yield* waitForFileContent(exitLogPath); expect(exitLog).toContain("exit:0"); - rmSync(exitLogDir, { recursive: true, force: true }); + NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); }), ); }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d72178b8ae..24676789b05 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, @@ -176,104 +176,100 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu Effect.scoped, ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CursorTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("CursorTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - const generated = yield* runCursorJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("CursorTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CursorTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("CursorTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CursorTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CursorTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runCursorJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 58ce165752c..85127b519b9 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -13,27 +13,27 @@ import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -const GrokTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { +const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-text-generation-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); function makeAcpGrokWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const grokPath = path.join(binDir, "grok"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( + const binDir = NodePath.join(dir, "bin"); + const grokPath = NodePath.join(binDir, "grok"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( grokPath, [ "#!/bin/sh", @@ -47,19 +47,19 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(grokPath, 0o755); + NodeFS.chmodSync(grokPath, 0o755); return grokPath; } function withFakeAcpGrok( env: Record, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-")); + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-acp-")); yield* Effect.addFinalizer(() => Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); + NodeFS.rmSync(tempDir, { recursive: true, force: true }); }), ); const binaryPath = makeAcpGrokWrapper(tempDir, env); @@ -72,7 +72,7 @@ function withFakeAcpGrok( function readJsonRpcRequests( filePath: string, ): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { - return readFileSync(filePath, "utf8") + return NodeFS.readFileSync(filePath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) @@ -81,8 +81,10 @@ function readJsonRpcRequests( it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => { it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-grok-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); return withFakeAcpGrok( { diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 6d7ff8e872d..ab52efb1116 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, @@ -169,104 +169,100 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.scoped, ); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "GrokTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); - const generated = yield* runGrokJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generated = yield* runGrokJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("GrokTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "GrokTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); + const generated = yield* runGrokJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("GrokTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "GrokTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runGrokJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "GrokTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); + const generated = yield* runGrokJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generated = yield* runGrokJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; }); - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..f6d9c133f38 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -9,13 +9,9 @@ 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 * as ServerConfig from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; const runtimeMock = { @@ -37,7 +33,7 @@ const runtimeMock = { }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; @@ -88,10 +84,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, @@ -107,11 +103,11 @@ const 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 +116,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,7 +139,7 @@ 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); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 65d3854e945..0ba7726d68c 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -15,7 +15,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,20 +23,13 @@ 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"; @@ -84,7 +77,7 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str } interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; + server: OpenCodeRuntime.OpenCodeServerProcess | null; /** * The scope that owns the shared server's lifetime. Closing this scope * terminates the OpenCode child process and interrupts any fibers the @@ -101,8 +94,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeSettings: OpenCodeSettings, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), @@ -135,7 +128,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, + server: OpenCodeRuntime.OpenCodeServerProcess, ) { yield* cancelIdleCloseFiber(); const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( @@ -217,7 +210,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ), @@ -240,7 +233,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) { @@ -278,7 +271,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { - const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); if (!parsedModel) { return yield* new TextGenerationError({ operation: input.operation, @@ -286,13 +279,13 @@ 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) => + const runAgainstServer = (server: Pick) => Effect.tryPromise({ try: async () => { const client = openCodeRuntime.createOpenCodeSdkClient({ @@ -336,7 +329,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" catch: (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), }); @@ -367,102 +360,98 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); }); - 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, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, + 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/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index f186d934e52..9bccb9c1fc5 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -9,23 +9,24 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; -import type { TextGenerationShape } from "./TextGeneration.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as TextGeneration from "./TextGeneration.ts"; -import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; - -const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ - generateCommitMessage: () => - Effect.die("generateCommitMessage stub not configured for this test"), - generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), - generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), - generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), - ...overrides, -}); +const makeStubTextGeneration = ( + overrides: Partial, +): TextGeneration.TextGeneration["Service"] => + TextGeneration.TextGeneration.of({ + generateCommitMessage: () => + Effect.die("generateCommitMessage stub not configured for this test"), + generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), + generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), + generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + ...overrides, + }); const makeStubInstance = ( instanceId: ProviderInstanceId, - textGeneration: TextGenerationShape, + textGeneration: TextGeneration.TextGeneration["Service"], ): ProviderInstance => ({ instanceId, @@ -43,7 +44,7 @@ const makeStubInstance = ( const makeStubRegistry = ( instances: ReadonlyArray, -): ProviderInstanceRegistryShape => { +): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => { const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); return { getInstance: (id) => Effect.succeed(byId.get(id)), @@ -81,7 +82,7 @@ describe("makeTextGenerationFromRegistry", () => { }), ); - const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); const result = yield* tg.generateBranchName({ cwd: process.cwd(), @@ -96,7 +97,7 @@ describe("makeTextGenerationFromRegistry", () => { it.effect("fails with TextGenerationError when the instance is unknown", () => Effect.gen(function* () { - const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([])); const result = yield* tg .generateBranchName({ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d5d28e638ed..e62a79afe78 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -4,10 +4,7 @@ import * as Layer from "effect/Layer"; import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; -import { - ProviderInstanceRegistry, - type ProviderInstanceRegistryShape, -} from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; @@ -79,45 +76,44 @@ export interface TextGenerationService { generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } -/** - * TextGenerationShape - Service API for commit/PR text generation. - */ -export interface TextGenerationShape { - /** - * Generate a commit message from staged change context. - */ - readonly generateCommitMessage: ( - input: CommitMessageGenerationInput, - ) => Effect.Effect; - - /** - * Generate pull request title/body from branch and diff context. - */ - readonly generatePrContent: ( - input: PrContentGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise branch name from a user message. - */ - readonly generateBranchName: ( - input: BranchNameGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise thread title from a user's first message. - */ - readonly generateThreadTitle: ( - input: ThreadTitleGenerationInput, - ) => Effect.Effect; -} - /** * TextGeneration - Service tag for commit and PR text generation. */ -export class TextGeneration extends Context.Service()( - "t3/textGeneration/TextGeneration", -) {} +export class TextGeneration extends Context.Service< + TextGeneration, + { + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate pull request title/body from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; + } +>()("t3/textGeneration/TextGeneration") {} + +/** @deprecated Use `TextGeneration["Service"]`. */ +export type TextGenerationShape = TextGeneration["Service"]; type TextGenerationOp = | "generateCommitMessage" @@ -126,7 +122,7 @@ type TextGenerationOp = | "generateThreadTitle"; const resolveInstance = ( - registry: ProviderInstanceRegistryShape, + registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], operation: TextGenerationOp, instanceId: ProviderInstanceId, ): Effect.Effect => @@ -144,30 +140,30 @@ const resolveInstance = ( ); export const makeTextGenerationFromRegistry = ( - registry: ProviderInstanceRegistryShape, -): TextGenerationShape => ({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), - ), + registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], +): TextGeneration["Service"] => + TextGeneration.of({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + ), + }); + +export const make = Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); }); -export const layer = Layer.effect( - TextGeneration, - Effect.gen(function* () { - const registry = yield* ProviderInstanceRegistry; - return makeTextGenerationFromRegistry(registry); - }), -); +export const layer = Layer.effect(TextGeneration, make); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 70bb8655ea1..89f7c55d586 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..55aa8f38835 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -28,7 +28,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput { remoteBranch: string; } +export interface GitFetchRemoteInput { + cwd: string; + remoteName: string; +} + +export interface GitResolveRemoteTrackingCommitInput { + cwd: string; + refName: string; + fallbackRemoteName: string; +} + +export interface GitResolveRemoteTrackingCommitResult { + commitSha: string; + remoteRefName: string; +} + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -168,76 +184,88 @@ export interface GitSetBranchUpstreamInput { remoteBranch: string; } -export interface GitVcsDriverShape { - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - readonly status: (input: VcsStatusInput) => Effect.Effect; - readonly statusDetails: (cwd: string) => Effect.Effect; - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - readonly statusDetailsRemote: ( - cwd: string, - ) => Effect.Effect; - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - options?: { readonly remoteName?: string | null }, - ) => Effect.Effect; - readonly readRangeContext: ( - cwd: string, - baseRef: string, - ) => Effect.Effect; - readonly getReviewDiffPreview: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly createWorktree: ( - input: VcsCreateWorktreeInput, - ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - readonly fetchRemoteTrackingBranch: ( - input: GitFetchRemoteTrackingBranchInput, - ) => Effect.Effect; - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - readonly createRef: ( - input: VcsCreateRefInput, - ) => Effect.Effect; - readonly switchRef: ( - input: VcsSwitchRefInput, - ) => Effect.Effect; - readonly initRepo: (input: VcsInitInput) => Effect.Effect; - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +export interface GitRemoteStatusOptions { + readonly refreshUpstream?: boolean; } -export class GitVcsDriver extends Context.Service()( - "t3/vcs/GitVcsDriver", -) {} +export class GitVcsDriver extends Context.Service< + GitVcsDriver, + { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly statusDetailsRemote: ( + cwd: string, + options?: GitRemoteStatusOptions, + ) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly getReviewDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: ( + input: VcsListRefsInput, + ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly resolveRemoteTrackingCommit: ( + input: GitResolveRemoteTrackingCommitInput, + ) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + } +>()("t3/vcs/GitVcsDriver") {} const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; @@ -332,7 +360,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcess.VcsProcessShape, + process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, @@ -376,7 +404,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => + const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.isInsideWorkTree", @@ -389,7 +417,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); - const execute: VcsDriver.VcsDriverShape["execute"] = (input) => + const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), @@ -401,7 +429,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( : {}), }); - const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { if (!(yield* isInsideWorkTree(cwd))) { @@ -427,7 +455,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }; }); - const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => + const listWorkspaceFiles: VcsDriver.VcsDriver["Service"]["listWorkspaceFiles"] = (cwd) => gitCommand( vcsProcess, "GitVcsDriver.listWorkspaceFiles", @@ -469,7 +497,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); - const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand( vcsProcess, @@ -515,7 +543,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ); - const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + const filterIgnoredPaths: VcsDriver.VcsDriver["Service"]["filterIgnoredPaths"] = Effect.fn( "filterIgnoredPaths", )(function* (cwd, relativePaths) { if (relativePaths.length === 0) { @@ -562,7 +590,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); - const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, @@ -623,7 +651,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; const gitCommonDir = yield* resolveGitCommonDir(input.cwd); - const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${randomUUID()}`); + const tempIndexPath = path.join( + gitCommonDir, + `t3-checkpoint-index-${NodeCrypto.randomUUID()}`, + ); const commitEnv: NodeJS.ProcessEnv = { ...process.env, GIT_INDEX_FILE: tempIndexPath, @@ -819,7 +850,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }; - return VcsDriver.VcsDriver.of({ + return { capabilities, execute, checkpoints, @@ -829,18 +860,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listRemotes, filterIgnoredPaths, initRepository, - }); + }; }); -export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { +export const makeVcsDriver = Effect.gen(function* () { const driver = yield* makeVcsDriverShape(); return VcsDriver.VcsDriver.of(driver); }); -export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); +export const make = Effect.gen(function* () { + const git = yield* makeGitVcsDriverCore(); return GitVcsDriver.of(git); }); -export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); -export const layer = Layer.effect(GitVcsDriver, make()); +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver); +export const layer = Layer.effect(GitVcsDriver, make); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 173d7649bd1..2fd4d447c58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -100,6 +100,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 +218,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("can read cached remote divergence without fetching upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const updater = yield* makeTmpDir("git-vcs-driver-updater-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + + yield* git(updater, ["clone", remote, "."]); + yield* git(updater, ["config", "user.email", "test@test.com"]); + yield* git(updater, ["config", "user.name", "Test"]); + yield* writeTextFile(updater, "remote.txt", "remote\n"); + yield* git(updater, ["add", "remote.txt"]); + yield* git(updater, ["commit", "-m", "remote commit"]); + yield* git(updater, ["push", "origin", initialBranch]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const cachedStatus = yield* driver.statusDetailsRemote(cwd, { + refreshUpstream: false, + }); + const refreshedStatus = yield* driver.statusDetailsRemote(cwd); + + assert.equal(cachedStatus.behindCount, 0); + assert.equal(refreshedStatus.behindCount, 1); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -313,6 +377,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { + it.effect("optionally includes remote refs that match local branches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const deduplicated = yield* driver.listRefs({ cwd }); + assert.equal( + deduplicated.refs.some((ref) => ref.name === `origin/${initialBranch}`), + false, + ); + + const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + assert.equal( + complete.refs.some((ref) => ref.name === initialBranch), + true, + ); + assert.equal( + complete.refs.some((ref) => ref.name === `origin/${initialBranch}`), + true, + ); + + const remoteOnly = yield* driver.listRefs({ + cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }); + assert.equal(remoteOnly.refs.length, 1); + assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); + assert.equal(remoteOnly.refs[0]?.isRemote, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -413,6 +515,77 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect("creates a worktree from the latest fetched remote commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const peer = yield* makeTmpDir("git-peer-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]); + const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]); + + yield* git(peer, ["clone", remote, "."]); + yield* git(peer, ["config", "user.email", "test@test.com"]); + yield* git(peer, ["config", "user.name", "Test"]); + yield* writeTextFile(peer, "remote-change.txt", "remote\n"); + yield* git(peer, ["add", "remote-change.txt"]); + yield* git(peer, ["commit", "-m", "remote change"]); + yield* git(peer, ["push", "origin", initialBranch]); + const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); + assert.notEqual(beforeFetch, remoteHead); + + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + + const resolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: initialBranch, + fallbackRemoteName: "origin", + }); + const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({ + cwd, + refName: `origin/${initialBranch}`, + fallbackRemoteName: "origin", + }); + + assert.deepEqual(resolvedBase, { + commitSha: remoteHead, + remoteRefName: `origin/${initialBranch}`, + }); + assert.deepEqual(explicitlyResolvedBase, resolvedBase); + assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); + + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-fetched-worktrees-"), + "fetched-origin", + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: resolvedBase.commitSha, + newRefName: "t3code/fetched-origin", + baseRefName: resolvedBase.remoteRefName, + }); + + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.gh-merge-base"), + initialBranch, + ); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), + null, + ); + const status = yield* driver.statusDetails(worktreePath); + assert.equal(status.aheadCount, 0); + assert.equal(status.aheadOfDefaultCount, 0); + }), + ); + it.effect("pushes with upstream setup and skips when already up to date", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a763026c23f..23a968a9cfe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -655,7 +655,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, @@ -756,7 +756,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, @@ -1059,38 +1059,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); - const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( - function* (input) { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout( - "GitVcsDriver.ensureRemote.listRemoteUrls", - input.cwd, - ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( + "ensureRemote", + )(function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; - } + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; } + } - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } - yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ - "remote", - "add", - remoteName, - input.url, - ]); - return remoteName; - }, - ); + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, @@ -1130,16 +1130,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; @@ -1426,33 +1426,34 @@ 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.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.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + } + return yield* readStatusDetailsRemote(cwd); + }); + + const status: GitVcsDriver.GitVcsDriver["Service"]["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1469,49 +1470,48 @@ 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.catch(() => Effect.void), + ); + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } - const stagedSummary = yield* runGitStdout( - "GitVcsDriver.prepareCommitContext.stagedSummary", - cwd, - ["diff", "--cached", "--name-status"], - ).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } - const stagedPatch = yield* runGitStdoutWithOptions( - "GitVcsDriver.prepareCommitContext.stagedPatch", - cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ); + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); - return { - stagedSummary, - stagedPatch, - }; - }); + return { + stagedSummary, + stagedPatch, + }; + }); - const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriver["Service"]["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, @@ -1544,7 +1544,7 @@ 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); @@ -1662,7 +1662,7 @@ 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); @@ -1708,7 +1708,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { const range = `${baseRef}..HEAD`; @@ -1815,7 +1815,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const dirtyTrackedResult = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.dirtyTracked", input.cwd, - ["diff", "--patch", "--minimal", "HEAD", "--"], + [ + "diff", + "--patch", + "--minimal", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + "HEAD", + "--", + ], { maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -1841,7 +1848,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, @@ -1906,13 +1919,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()), @@ -2125,11 +2138,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; + const allBranches = input.includeMatchingRemoteRefs + ? [...localBranches, ...remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + const branchesForKind = + input.refKind === "local" + ? allBranches.filter((ref) => !ref.isRemote) + : input.refKind === "remote" + ? allBranches.filter((ref) => ref.isRemote) + : allBranches; const refs = paginateBranches({ - refs: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), + refs: filterBranchesForListQuery(branchesForKind, input.query), cursor: input.cursor, limit: input.limit, }); @@ -2144,7 +2163,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; @@ -2159,6 +2178,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorMessage: "git worktree add failed", }); + if (input.newRefName && input.baseRefName) { + const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); + const parsedBaseRef = parseRemoteRefWithRemoteNames( + input.baseRefName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const baseBranch = parsedBaseRef?.branchName ?? input.baseRefName; + yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ + "config", + `branch.${input.newRefName}.gh-merge-base`, + baseBranch, + ]); + } + return { worktree: { path: worktreePath, @@ -2167,7 +2200,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( @@ -2186,7 +2219,39 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - 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, + fallbackErrorMessage: `git fetch ${input.remoteName} failed`, + }, + ); + }, + ); + + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = + Effect.fn("resolveRemoteTrackingCommit")(function* (input) { + const remoteNames = yield* listRemoteNames(input.cwd); + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + input.refName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + const remoteRefName = + parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; + const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ + "rev-parse", + "--verify", + `refs/remotes/${remoteRefName}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha, remoteRefName }; + }); + + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ @@ -2208,7 +2273,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = Effect.fn("fetchRemoteTrackingBranch")(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ "fetch", @@ -2219,7 +2284,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); }); - const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => + const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", @@ -2227,7 +2292,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"]; @@ -2251,28 +2316,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - 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, + fallbackErrorMessage: "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( [ @@ -2354,7 +2419,7 @@ 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, @@ -2368,13 +2433,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", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + const listLocalBranchNames: GitVcsDriver.GitVcsDriver["Service"]["listLocalBranchNames"] = ( + cwd, + ) => runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", @@ -2411,6 +2478,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchPullRequestBranch, ensureRemote, resolvePrimaryRemoteName, + fetchRemote, + resolveRemoteTrackingCommit, fetchRemoteBranch, fetchRemoteTrackingBranch, setBranchUpstream, diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 1885a49ce92..f2daf793502 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -52,26 +52,29 @@ export interface VcsCheckpointOps { ) => Effect.Effect; } -export interface VcsDriverShape { - readonly capabilities: VcsDriverCapabilities; - readonly execute: ( - input: Omit, - ) => Effect.Effect; - readonly checkpoints?: VcsCheckpointOps; - readonly detectRepository: (cwd: string) => Effect.Effect; - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - readonly listRemotes: (cwd: string) => Effect.Effect; - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, VcsError>; - readonly initRepository: (input: VcsInitInput) => Effect.Effect; - readonly getDiffPreview?: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; -} - -export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} +export class VcsDriver extends Context.Service< + VcsDriver, + { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly checkpoints?: VcsCheckpointOps; + readonly detectRepository: ( + cwd: string, + ) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + readonly getDiffPreview?: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 03c09c16be8..7a531a5adcc 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,7 +21,7 @@ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => describe("VcsDriverRegistry", () => { it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ @@ -45,7 +45,7 @@ describe("VcsDriverRegistry", () => { it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { const calls: VcsProcess.VcsProcessInput[] = []; - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(VcsProjectConfig.VcsProjectConfig)({ diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 22868855737..103cc9607c1 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -22,20 +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", -) {} +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") {} const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => new VcsUnsupportedOperationError({ @@ -68,14 +67,14 @@ 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( @@ -87,7 +86,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 +122,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) { @@ -155,6 +154,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }); }); -export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( +export const layer = Layer.effect(VcsDriverRegistry, make).pipe( Layer.provide(VcsProjectConfig.layer), ); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index b58d64e435a..e13120b1c57 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -6,7 +6,11 @@ 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 VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -61,14 +65,79 @@ 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), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index a4caf7d3230..8103c7306a0 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,17 +1,18 @@ 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, VcsProcessSpawnError, 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 +36,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 @@ -98,13 +130,15 @@ export const make = Effect.fn("makeVcsProcess")(function* () { } 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 +153,4 @@ export const make = Effect.fn("makeVcsProcess")(function* () { return VcsProcess.of({ run }); }); -export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); +export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..5fe5dcc7564 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,46 @@ 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 [message, context] = messages[0] as [string, Record]; + const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); + assert.equal(message, "Failed to inspect VCS project config at " + failedCandidate + "."); + assert.deepInclude(context, { + operation: "inspect", + cwd, + configPath: failedCandidate, + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).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 +124,97 @@ 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 [message, context] = messages[0] as [string, Record]; + assert.equal( + message, + "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", + ); + assert.deepInclude(context, { + operation: "decode", + cwd: root, + configPath: path.join(configDir, "vcs.json"), + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).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 [message, context] = messages[0] as [string, Record]; + assert.equal(message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(context, { + operation: "read", + cwd: root, + configPath, + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + `{"vcs":{"kind":"svn"}}`, + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..bd8f4515007 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,53 @@ 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.message, { + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + stack: error.stack, + }); -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 +71,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 +152,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { }); }); -export const layer = Layer.effect(VcsProjectConfig, make()); +export const layer = Layer.effect(VcsProjectConfig, make); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index ba919a5f435..0a28f9c9b2c 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -11,7 +11,7 @@ import * as VcsProvisioningService from "./VcsProvisioningService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { +function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { return { capabilities: { kind: "git", diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 38006b4b603..9febacf2256 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -10,13 +10,11 @@ import { } from "@t3tools/contracts"; import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -export interface VcsProvisioningServiceShape { - readonly initRepository: (input: VcsInitInput) => Effect.Effect; -} - export class VcsProvisioningService extends Context.Service< VcsProvisioningService, - VcsProvisioningServiceShape + { + readonly initRepository: (input: VcsInitInput) => Effect.Effect; + } >()("t3/vcs/VcsProvisioningService") {} function resolveRequestedKind( @@ -37,10 +35,10 @@ function resolveRequestedKind( return Effect.succeed(kind); } -export const make = Effect.fn("makeVcsProvisioningService")(function* () { +export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( + const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", )(function* (input) { const kind = yield* resolveRequestedKind(input.kind); @@ -53,4 +51,4 @@ export const make = Effect.fn("makeVcsProvisioningService")(function* () { }); }); -export const layer = Layer.effect(VcsProvisioningService, make()); +export const layer = Layer.effect(VcsProvisioningService, make); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 7c5768162a9..c14115e7119 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -43,6 +43,18 @@ const baseRemoteStatus: VcsStatusRemoteResult = { pr: null, }; +const remoteStatusWithPr: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { + number: 2978, + title: "[codex] Rewrite client connection architecture", + url: "https://github.com/pingdotgg/t3code/pull/2978", + baseRef: "main", + headRef: "codex/connection-state-audit", + state: "open", + }, +}; + const baseStatus: VcsStatusResult = { ...baseLocalStatus, ...baseRemoteStatus, @@ -55,6 +67,7 @@ function makeTestLayer(state: { remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; + remoteStatusRefreshUpstreamValues?: Array; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -65,9 +78,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: () => @@ -285,7 +299,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -352,29 +366,146 @@ 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]); - assert.isTrue(Option.isSome(snapshot)); - assert.equal(state.remoteStatusCalls, 0); + yield* TestClock.adjust(Duration.minutes(2)); + assert.equal(state.remoteStatusCalls, 1); assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(makeTestLayer(state))); + + 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, + }; + 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", + detail: "initial remote status failed", + }), + ).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: "/repo" }, + { 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); + + 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); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); }); it.effect("delays automatic refresh when a cached remote snapshot is available", () => { @@ -486,7 +617,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d83dc26fbed..860fc8075b3 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -65,23 +65,21 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } -export interface VcsStatusBroadcasterShape { - readonly getStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: VcsStatusInput, - options?: StreamStatusOptions, - ) => Stream.Stream; -} - export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, - VcsStatusBroadcasterShape + { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; + } >()("t3/vcs/VcsStatusBroadcaster") {} function fingerprintStatusPart(status: unknown): string { @@ -94,101 +92,57 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -export const layer = Layer.effect( - VcsStatusBroadcaster, - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const fs = yield* FileSystem.FileSystem; - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded(), - (pubsub) => PubSub.shutdown(pubsub), - ); - const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* SynchronizedRef.make(new Map()); +export const make = Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); - const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, - ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); - const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } - - return local; - }, - ); - - const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* ( - cwd: string, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, }); + } - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote, - }, - }); - } - - return remote; - }, - ); + return local; + }, + ); - const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, @@ -197,255 +151,302 @@ export const layer = Layer.effect( const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { - local: nextLocal, + ...previous, remote: nextRemote, }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; }); if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { - _tag: "snapshot", - local, + _tag: "remoteUpdated", remote, }, }); } - return mergeGitStatusParts(local, remote); - }); + return remote; + }, + ); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, - ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( + cwd: string, + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + local: nextLocal, + remote: nextRemote, + }); + return [ + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + nextCache, + ] as const; }); - const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, - ) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - return cached.local.value; - } - return yield* loadLocalStatus(cwd); - }); + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + return mergeGitStatusParts(local, remote); + }); - const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( - "VcsStatusBroadcaster.getStatus", - )(function* (input) { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const cached = yield* getCachedStatus(cwd); - if (cached?.local && cached.remote) { - return mergeGitStatusParts(cached.local.value, cached.remote.value); - } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote); - }); + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); - const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); - }, + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const cached = yield* getCachedStatus(cwd); + if (cached?.local && cached.remote) { + return mergeGitStatusParts(cached.local.value, cached.remote.value); + } + const [local, remote] = yield* Effect.all( + [ + cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), + cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, ); + return yield* updateCachedStatus(cwd, local, remote); + }); - const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshLocalStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); - }); + const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( + function* (cwd: string) { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }, + ); - const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( - cwd: string, - ) { - yield* workflow.invalidateRemoteStatus(cwd); - const remote = yield* workflow.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); - }); + const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + return yield* refreshLocalStatusCore(cwd); + }); - const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( - "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all( - [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], - { concurrency: "unbounded", discard: true }, - ); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + const 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 }, 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 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 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; + } - if (!refreshImmediately) { - const configuredInterval = yield* automaticRemoteRefreshInterval; - yield* Effect.sleep( - Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval, - ); + 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; } - 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", { + cwd, + detail: exit.cause.toString(), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), + }); + return nextDelay; }); - }; - const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( - cwd: string, - automaticRemoteRefreshInterval: Effect.Effect, - refreshImmediately: boolean, - ) { - yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (existing) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount + 1, - }); - return Effect.succeed([undefined, nextPollers] as const); - } - - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), + if (!refreshImmediately) { + const configuredInterval = yield* automaticRemoteRefreshInterval; + yield* Effect.sleep( + Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval, ); - }); + } + + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay((delay) => Effect.succeed(delay)), + ), + ), + Effect.asVoid, + ); }); + }; - const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( - cwd: string, - ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (!existing) { - return [null, activePollers] as const; - } + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + refreshImmediately: boolean, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } - if (existing.subscriberCount > 1) { + return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { const nextPollers = new Map(activePollers); nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount - 1, + fiber, + subscriberCount: 1, }); - return [null, nextPollers] as const; - } + return [undefined, nextPollers] as const; + }), + ); + }); + }); - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; - }); + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } - if (pollerToInterrupt) { - yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; }); - const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => - Stream.unwrap( - Effect.gen(function* () { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); - const initialRemote = cachedStatus?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); - }), - ); + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const cachedStatus = yield* getCachedStatus(cwd); + const initialRemote = cachedStatus?.remote?.value ?? null; + yield* retainRemotePoller( + cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); - return VcsStatusBroadcaster.of({ - getStatus, - refreshLocalStatus, - refreshStatus, - streamStatus, - }); - }), -); + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); +}); + +export const layer = Layer.effect(VcsStatusBroadcaster, make); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts deleted file mode 100644 index 61056042bf3..00000000000 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ /dev/null @@ -1,123 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import fsPromises from "node:fs/promises"; - -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - WorkspaceFileSystem, - WorkspaceFileSystemError, - type WorkspaceFileSystemShape, -} from "../Services/WorkspaceFileSystem.ts"; -import * as WorkspaceEntries from "../WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; - -export const makeWorkspaceFileSystem = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - - const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( - function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - const result = yield* Effect.tryPromise({ - try: async () => { - const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - fsPromises.realpath(input.cwd), - fsPromises.realpath(target.absolutePath), - ]); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { - throw new Error("Workspace file path resolves outside the project root."); - } - - const handle = await fsPromises.open(realTargetPath, "r"); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new Error("Workspace path is not a file."); - } - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); - const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); - const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { - throw new Error("Binary files cannot be previewed as text."); - } - const contents = new TextDecoder("utf-8").decode(fileBytes); - return { - relativePath: target.relativePath, - contents, - byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - } finally { - await handle.close(); - } - }, - catch: (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.readFile", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); - - return result; - }, - ); - - const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( - "WorkspaceFileSystem.writeFile", - )(function* (input) { - const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - }); - - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.makeDirectory", - detail: cause.message, - cause, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", - detail: cause.message, - cause, - }), - ), - ); - yield* workspaceEntries.refresh(input.cwd); - return { relativePath: target.relativePath }; - }); - return { readFile, writeFile } satisfies WorkspaceFileSystemShape; -}); - -export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts deleted file mode 100644 index dfe02e8f67c..00000000000 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { - WorkspacePaths, - WorkspacePathOutsideRootError, - WorkspaceRootCreateFailedError, - WorkspaceRootNotDirectoryError, - WorkspaceRootNotExistsError, - type WorkspacePathsShape, -} from "../Services/WorkspacePaths.ts"; - -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - -export const makeWorkspacePaths = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( - "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - let workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); - if (!workspaceStat && options?.createIfMissing) { - yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( - Effect.mapError( - () => - new WorkspaceRootCreateFailedError({ - workspaceRoot, - normalizedWorkspaceRoot, - }), - ), - ); - workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); - } - if (!workspaceStat) { - return yield* new WorkspaceRootNotExistsError({ - workspaceRoot, - normalizedWorkspaceRoot, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new WorkspaceRootNotDirectoryError({ - workspaceRoot, - normalizedWorkspaceRoot, - }); - } - return normalizedWorkspaceRoot; - }); - - const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] = - Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { - const normalizedInputPath = input.relativePath.trim(); - if (path.isAbsolute(normalizedInputPath)) { - return yield* new WorkspacePathOutsideRootError({ - workspaceRoot: input.workspaceRoot, - relativePath: input.relativePath, - }); - } - - const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || - relativeToRoot.startsWith("../") || - relativeToRoot === ".." || - path.isAbsolute(relativeToRoot) - ) { - return yield* new WorkspacePathOutsideRootError({ - workspaceRoot: input.workspaceRoot, - relativePath: input.relativePath, - }); - } - - return { - absolutePath, - relativePath: relativeToRoot, - }; - }); - - return { - normalizeWorkspaceRoot, - resolveRelativePathWithinRoot, - } satisfies WorkspacePathsShape; -}); - -export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts deleted file mode 100644 index 5126ec417bf..00000000000 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * WorkspaceFileSystem - Effect service contract for workspace file mutations. - * - * Owns workspace-root-relative file write operations and their associated - * safety checks and cache invalidation hooks. - * - * @module WorkspaceFileSystem - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - ProjectReadFileInput, - ProjectReadFileResult, - ProjectWriteFileInput, - ProjectWriteFileResult, -} from "@t3tools/contracts"; -import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; - -export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( - "WorkspaceFileSystemError", - { - cwd: Schema.String, - relativePath: Schema.optional(Schema.String), - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return this.detail; - } -} - -/** - * WorkspaceFileSystemShape - Service API for workspace-relative file operations. - */ -export interface WorkspaceFileSystemShape { - /** - * Read a UTF-8 text file relative to the workspace root. - */ - readonly readFile: ( - input: ProjectReadFileInput, - ) => Effect.Effect< - ProjectReadFileResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; - - /** - * Write a file relative to the workspace root. - * - * Creates parent directories as needed and rejects paths that escape the - * workspace root. - */ - readonly writeFile: ( - input: ProjectWriteFileInput, - ) => Effect.Effect< - ProjectWriteFileResult, - WorkspaceFileSystemError | WorkspacePathOutsideRootError - >; -} - -/** - * WorkspaceFileSystem - Service tag for workspace file operations. - */ -export class WorkspaceFileSystem extends Context.Service< - WorkspaceFileSystem, - WorkspaceFileSystemShape ->()("t3/workspace/Services/WorkspaceFileSystem") {} diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts deleted file mode 100644 index 7c57ca19bd2..00000000000 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * WorkspacePaths - Effect service contract for workspace path handling. - * - * Owns normalization and validation of workspace roots plus safe resolution of - * workspace-root-relative paths. - * - * @module WorkspacePaths - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( - "WorkspaceRootNotExistsError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( - "WorkspaceRootCreateFailedError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( - "WorkspaceRootNotDirectoryError", - { - workspaceRoot: Schema.String, - normalizedWorkspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; - } -} - -export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( - "WorkspacePathOutsideRootError", - { - workspaceRoot: Schema.String, - relativePath: Schema.String, - }, -) { - override get message(): string { - return `Workspace file path must be relative to the project root: ${this.relativePath}`; - } -} - -export const WorkspacePathsError = Schema.Union([ - WorkspaceRootNotExistsError, - WorkspaceRootCreateFailedError, - WorkspaceRootNotDirectoryError, - WorkspacePathOutsideRootError, -]); -export type WorkspacePathsError = typeof WorkspacePathsError.Type; - -/** - * WorkspacePathsShape - Service API for workspace path normalization and guards. - */ -export interface WorkspacePathsShape { - /** - * Normalize a user-provided workspace root and verify it exists as a directory. - */ - readonly normalizeWorkspaceRoot: ( - workspaceRoot: string, - options?: { readonly createIfMissing?: boolean }, - ) => Effect.Effect< - string, - WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError - >; - - /** - * Resolve a relative path within a validated workspace root. - * - * Rejects absolute paths and traversal attempts outside the workspace root. - */ - readonly resolveRelativePathWithinRoot: (input: { - workspaceRoot: string; - relativePath: string; - }) => Effect.Effect< - { absolutePath: string; relativePath: string }, - WorkspacePathOutsideRootError - >; -} - -/** - * WorkspacePaths - Service tag for workspace path normalization and resolution. - */ -export class WorkspacePaths extends Context.Service()( - "t3/workspace/Services/WorkspacePaths", -) {} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index f8a518d8b33..a08350ed959 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,26 +1,32 @@ // @effect-diagnostics nodeBuiltinImport:off -import fsPromises from "node:fs/promises"; +import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { FileFinder } from "@ff-labs/fff-node"; -import { it, afterEach, describe, expect, vi } from "@effect/vitest"; +import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { vi } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), Layer.provide( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", }), ), @@ -363,7 +369,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }) .pipe(Effect.flip); - expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); + expect(error.message).toBe( + "A current project is required to browse relative workspace path './src'.", + ); }), ); @@ -373,7 +382,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); + vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied); const result = yield* workspaceEntries.browse({ partialPath: yield* appendSeparator(cwd), diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bf9a51c74db..cdb26a38bc7 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -20,29 +20,65 @@ import type { import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", +export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesWindowsPathUnsupportedError", { - cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + platform: Schema.String, + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`; + } +} + +export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesCurrentProjectRequiredError", + { + partialPath: Schema.String, }, -) {} +) { + override get message(): string { + return `A current project is required to browse relative workspace path '${this.partialPath}'.`; + } +} -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", +export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesReadDirectoryError", { cwd: Schema.optional(Schema.String), partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + parentPath: Schema.String, + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`; + } +} + +export const WorkspaceEntriesBrowseError = Schema.Union([ + WorkspaceEntriesWindowsPathUnsupportedError, + WorkspaceEntriesCurrentProjectRequiredError, + WorkspaceEntriesReadDirectoryError, +]); +export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; + +export const WorkspaceEntriesError = Schema.Union([ + WorkspacePaths.WorkspaceRootNotExistsError, + WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootNotDirectoryError, + WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, + WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, + WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed, +]); +export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type; export class WorkspaceEntries extends Context.Service< WorkspaceEntries, @@ -70,38 +106,32 @@ function expandHomePath(input: string, path: Path.Path): string { return input; } -const resolveBrowseTarget = ( +const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Windows-style paths are only supported on Windows.", - }); - } - - if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath, path)); - } - - if (!input.cwd) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Relative filesystem browse paths require a current project.", - }); - } - - return path.resolve(expandHomePath(input.cwd, path), input.partialPath); - }); +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesWindowsPathUnsupportedError({ + cwd: input.cwd, + partialPath: input.partialPath, + platform, + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return path.resolve(expandHomePath(input.partialPath, path)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesCurrentProjectRequiredError({ + partialPath: input.partialPath, + }); + } + return path.resolve(expandHomePath(input.cwd, path), input.partialPath); +}); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; @@ -109,17 +139,7 @@ const make = Effect.gen(function* () { const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, ): Effect.fn.Return { - return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.normalizeWorkspaceRoot", - detail: cause.message, - cause, - }), - ), - ); + return yield* workspacePaths.normalizeWorkspaceRoot(cwd); }); const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( @@ -158,11 +178,10 @@ const make = Effect.gen(function* () { const dirents = yield* Effect.tryPromise({ try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), catch: (cause) => - new WorkspaceEntriesBrowseError({ + new WorkspaceEntriesReadDirectoryError({ cwd: input.cwd, partialPath: input.partialPath, - operation: "workspaceEntries.browse.readDirectory", - detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + parentPath, cause, }), }).pipe( @@ -208,18 +227,7 @@ const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.search", - detail: cause.message, - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); @@ -229,18 +237,7 @@ const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd: input.cwd, - operation: "workspaceEntries.list", - detail: cause.message, - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts similarity index 55% rename from apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts rename to apps/server/src/workspace/WorkspaceFileSystem.test.ts index 5a4ec54686e..cecffbc1993 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -5,26 +5,25 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { ServerConfig } from "../../config.ts"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import * as WorkspaceEntries from "../WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; - -const ProjectLayer = WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), +import * as ServerConfig from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const ProjectLayer = WorkspaceFileSystem.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), ); const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", }), ), @@ -56,7 +55,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); @@ -76,7 +75,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const error = yield* workspaceFileSystem @@ -91,7 +90,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects symlinks that resolve outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cwd = yield* makeTempDir; @@ -105,8 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const error = yield* workspaceFileSystem .readFile({ cwd, relativePath: "linked-secret.txt" }) .pipe(Effect.flip); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-secret.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects directories without manufacturing an I/O cause", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); - expect(error.message).toContain("resolves outside the project root"); + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "src", + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects binary files without leaking their contents into the error", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const absolutePath = path.join(cwd, "asset.bin"); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "asset.bin" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(absolutePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "asset.bin", + resolvedPath, + }); + expect("cause" in error).toBe(false); + expect("contents" in error).toBe(false); + }), + ); + + it.effect("preserves the real cause and path for I/O failures", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const resolvedPath = path.join(cwd, "missing.txt"); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "missing.txt" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "missing.txt", + resolvedPath, + operationPath: resolvedPath, + operation: "realpath-target", + }); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); }), ); }); @@ -114,7 +194,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i describe("writeFile", () => { it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,7 +215,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); @@ -160,7 +240,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i it.effect("rejects writes outside the workspace root", () => Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const cwd = yield* makeTempDir; const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts new file mode 100644 index 00000000000..e2dc9cbbb39 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -0,0 +1,303 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * WorkspaceFileSystem - Effect service contract for workspace file mutations. + * + * Owns workspace-root-relative file read/write operations and their associated + * safety checks and cache invalidation hooks. + * + * @module WorkspaceFileSystem + */ +import * as NodeFSP from "node:fs/promises"; + +import type { + ProjectReadFileInput, + ProjectReadFileResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; + +export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemOperationError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + operationPath: Schema.String, + operation: Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; + } +} + +export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass()( + "WorkspaceFilePathEscapeError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedWorkspaceRoot: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`; + } +} + +export class WorkspacePathNotFileError extends Schema.TaggedErrorClass()( + "WorkspacePathNotFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`; + } +} + +export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass()( + "WorkspaceBinaryFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`; + } +} + +export const WorkspaceFileSystemError = Schema.Union([ + WorkspaceFileSystemOperationError, + WorkspaceFilePathEscapeError, + WorkspacePathNotFileError, + WorkspaceBinaryFileError, +]); +export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; + +/** Service tag for workspace file operations. */ +export class WorkspaceFileSystem extends Context.Service< + WorkspaceFileSystem, + { + /** Read a UTF-8 text file relative to the workspace root. */ + readonly readFile: ( + input: ProjectReadFileInput, + ) => Effect.Effect< + ProjectReadFileResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + /** + * Write a file relative to the workspace root. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly writeFile: ( + input: ProjectWriteFileInput, + ) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + } +>()("t3/workspace/WorkspaceFileSystem") {} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + }); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(realTargetPath, "r"), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "open", + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "stat", + cause, + }), + }); + if (!stat.isFile()) { + return yield* new WorkspacePathNotFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = yield* Effect.tryPromise({ + try: () => handle.read(buffer, 0, bytesToRead, 0), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "read", + cause, + }), + }); + const fileBytes = buffer.subarray(0, bytesRead); + if (fileBytes.includes(0)) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + + return { + relativePath: target.relativePath, + contents: new TextDecoder("utf-8").decode(fileBytes), + byteLength: stat.size, + truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + }; + }), + (handle) => + Effect.tryPromise({ + try: () => handle.close(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "close", + cause, + }), + }), + ); + }); + + const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( + "WorkspaceFileSystem.writeFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: path.dirname(target.absolutePath), + operation: "make-directory", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", + cause, + }), + ), + ); + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + return WorkspaceFileSystem.of({ readFile, writeFile }); +}); + +export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts similarity index 88% rename from apps/server/src/workspace/Layers/WorkspacePaths.test.ts rename to apps/server/src/workspace/WorkspacePaths.test.ts index 0a9252a7def..ecce54b67d6 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -5,11 +5,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -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 +37,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 +48,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 +62,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 +80,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"); @@ -97,7 +96,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { 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 +114,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { it.effect("rejects paths that escape the workspace root", () => Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const cwd = yield* makeTempDir(); const error = yield* workspacePaths diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts new file mode 100644 index 00000000000..85e3db561c4 --- /dev/null +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -0,0 +1,191 @@ +/** + * 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 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; + +/** 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 | 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 normalizeWorkspaceRoot: WorkspacePaths["Service"]["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( + (cause) => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + cause, + }), + ), + ); + 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: 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.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 4bee3cbc089..fcacf3caf13 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -182,64 +182,69 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( ); }); -const makeWorkspaceSearchIndex = (cwd: string) => - Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( - Effect.tap((finder) => waitForScan(cwd, finder)), - Effect.map((finder) => { - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( - query: string, - pageSize: number, - ) { - const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); - if (!result.ok) { - return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); - } - return result.value; - }); - - const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( - "WorkspaceSearchIndex.refresh", - )(function* () { - const result = yield* Effect.sync(() => finder.scanFiles()); - if (!result.ok) { - return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); - } - yield* waitForScan(cwd, finder); - }); - - const 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, - }; - }, - ); +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { + const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => + Effect.sync(() => finder.destroy()), + ); + yield* waitForScan(cwd, finder); + + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + query: string, + pageSize: number, + ) { + const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); + if (!result.ok) { + return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); + } + return result.value; + }); - const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( - "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); - return mapMixedSearchResult(result, limit); - }); + const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( + "WorkspaceSearchIndex.refresh", + )(function* () { + const result = yield* Effect.sync(() => finder.scanFiles()); + if (!result.ok) { + return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); + } + yield* waitForScan(cwd, finder); + }); - return WorkspaceSearchIndex.of({ list, refresh, search }); - }), + 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 workspaceSearchIndexLayer = (cwd: string) => - Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); + 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: workspaceSearchIndexLayer, + lookup: layer, idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, }, ) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84f..7c45d0b58b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,9 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectEntriesFailure, + type ProjectFileFailure, + type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, @@ -41,6 +44,7 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + type FilesystemBrowseFailure, FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, @@ -56,45 +60,44 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { ServerConfig } from "./config.ts"; -import { Keybindings } from "./keybindings.ts"; +import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; -import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; -import { TerminalManager } from "./terminal/Services/Manager.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; -import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; -import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; -import { GitWorkflowService } from "./git/GitWorkflowService.ts"; -import { ReviewService } from "./review/ReviewService.ts"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as ReviewService from "./review/ReviewService.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; -import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; -import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -109,10 +112,137 @@ 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 "WorkspaceRootNotDirectoryError": + return { + failure: "workspace_root_not_directory", + normalizedCwd: error.normalizedWorkspaceRoot, + }; + case "WorkspaceSearchIndexCreateFailed": + return { + failure: "search_index_create_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; + case "WorkspaceSearchIndexScanTimedOut": + return { + failure: "search_index_scan_timed_out", + normalizedCwd: error.cwd, + timeout: error.timeout, + }; + case "WorkspaceSearchIndexSearchFailed": + return { + failure: "search_index_search_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; + default: + return unexpectedCompatibilityError(error); + } +} + +function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; +} { + switch (error._tag) { + case "WorkspaceEntriesWindowsPathUnsupportedError": + return { failure: "windows_path_unsupported", platform: error.platform }; + case "WorkspaceEntriesCurrentProjectRequiredError": + return { failure: "current_project_required" }; + case "WorkspaceEntriesReadDirectoryError": + return { failure: "read_directory_failed", parentPath: error.parentPath }; + default: + return unexpectedCompatibilityError(error); + } +} + +function projectFileFailureContext( + error: + | WorkspaceFileSystem.WorkspaceFileSystemError + | WorkspacePaths.WorkspacePathOutsideRootError, +): { + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; +} { + switch (error._tag) { + case "WorkspacePathOutsideRootError": + return { failure: "workspace_path_outside_root" }; + case "WorkspaceFileSystemOperationError": + return { + failure: "operation_failed", + resolvedPath: error.resolvedPath, + operation: error.operation, + operationPath: error.operationPath, + }; + case "WorkspaceFilePathEscapeError": + return { + failure: "resolved_path_outside_root", + resolvedPath: error.resolvedPath, + resolvedWorkspaceRoot: error.resolvedWorkspaceRoot, + }; + case "WorkspacePathNotFileError": + return { failure: "path_not_file", resolvedPath: error.resolvedPath }; + case "WorkspaceBinaryFileError": + return { failure: "binary_file", resolvedPath: error.resolvedPath }; + default: + return unexpectedCompatibilityError(error); + } +} + +function projectSetupScriptCompatibilityDetail( + error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, +): string { + switch (error._tag) { + case "ProjectSetupScriptOperationError": + return legacySetupFailureDescription(error.cause); + case "ProjectSetupScriptProjectNotFoundError": + return "Project was not found for setup script execution."; + default: + return unexpectedCompatibilityError(error); + } +} + function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { @@ -248,37 +378,38 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - const gitWorkflow = yield* GitWorkflowService; - const review = yield* ReviewService; - const vcsProvisioning = yield* VcsProvisioningService; - const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; - const terminalManager = yield* TerminalManager; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const review = yield* ReviewService.ReviewService; + const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const terminalManager = yield* TerminalManager.TerminalManager; const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; - const providerRegistry = yield* ProviderRegistry; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; + const config = yield* ServerConfig.ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const repositoryIdentityResolver = + yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -287,7 +418,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ); - const sourceControlRepositories = yield* SourceControlRepositoryService; + const sourceControlRepositories = + yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -560,12 +692,11 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => : Effect.void; const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; + readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; readonly requestedAt: string; readonly worktreePath: string; }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; + const detail = projectSetupScriptCompatibilityDetail(input.error); return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -694,10 +825,24 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => } if (bootstrap?.prepareWorktree) { + let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; + if (bootstrap.prepareWorktree.startFromOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: bootstrap.prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: bootstrap.prepareWorktree.projectCwd, + refName: bootstrap.prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, + refName: worktreeBaseRef, newRefName: bootstrap.prepareWorktree.branch, + baseRefName: bootstrap.prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -753,7 +898,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -1055,7 +1202,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverGetSettings]: (_input) => observeRpcEffect( WS_METHODS.serverGetSettings, - serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), + serverSettings.getSettings.pipe( + Effect.map(ServerSettings.redactServerSettingsForClient), + ), { "rpc.aggregate": "server", }, @@ -1063,7 +1212,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, - serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), + serverSettings + .updateSettings(patch) + .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), { "rpc.aggregate": "server", }, @@ -1169,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1183,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${cause.detail}`, + ...input, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1194,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${cause.detail}`; - return new ProjectReadFileError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1207,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1230,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: cause.detail, + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), @@ -1476,7 +1634,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.previewAutomationConnect]: (input) => observeRpcStreamEffect( WS_METHODS.previewAutomationConnect, - previewAutomationBroker.connect(input.clientId), + previewAutomationBroker.connect(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.previewAutomationRespond]: (input) => @@ -1494,7 +1652,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.previewAutomationClearOwner]: (input) => observeRpcEffect( WS_METHODS.previewAutomationClearOwner, - previewAutomationBroker.clearOwner(input.clientId), + previewAutomationBroker.clearOwner(input), { "rpc.aggregate": "preview-automation" }, ), [WS_METHODS.subscribePreviewEvents]: (_input) => @@ -1546,7 +1704,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => redactServerSettingsForClient(settings)), + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -1635,10 +1793,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, @@ -1649,7 +1809,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( - SourceControlDiscoveryLayer.layer.pipe( + SourceControlDiscovery.layer.pipe( Layer.provide( SourceControlProviderRegistry.layer.pipe( Layer.provide( diff --git a/apps/web/package.json b/apps/web/package.json index d6751c73486..632e2d14395 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,14 +8,12 @@ "build": "vp build", "preview": "vp preview", "typecheck": "tsgo --noEmit", - "test": "vp test run --passWithNoTests --project unit", - "test:browser": "vp test run --project browser", - "test:browser:install": "playwright install --with-deps chromium" + "test": "vp test run --passWithNoTests --project unit" }, "dependencies": { "@base-ui/react": "^1.4.1", - "@clerk/clerk-js": "^6.16.0", - "@clerk/react": "^6.9.0", + "@clerk/electron": "catalog:", + "@clerk/react": "catalog:", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -32,7 +30,6 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", - "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", @@ -64,10 +61,8 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", - "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "vite": "catalog:", - "vite-plus": "catalog:", - "vitest-browser-react": "^2.0.5" + "vite-plus": "catalog:" } } diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx new file mode 100644 index 00000000000..9112e31cb86 --- /dev/null +++ b/apps/web/src/AppRoot.test.tsx @@ -0,0 +1,22 @@ +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { RouterProvider } from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import type { AppRouter } from "./router"; +import { AppRoot } from "./AppRoot"; + +describe("AppRoot", () => { + it("shares the application atom registry with routed UI and the Electron browser host", () => { + const root = AppRoot({ router: {} as AppRouter }); + + expect(root.type).toBe(AppAtomRegistryProvider); + const children = Children.toArray( + (root as ReactElement<{ readonly children: ReactNode }>).props.children, + ); + expect(children).toHaveLength(2); + expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); + expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); + }); +}); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx new file mode 100644 index 00000000000..1ecb9f6b7b6 --- /dev/null +++ b/apps/web/src/AppRoot.tsx @@ -0,0 +1,19 @@ +import { RouterProvider } from "@tanstack/react-router"; + +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import type { AppRouter } from "./router"; + +/** + * Owns renderer-wide providers. The Electron browser host intentionally sits + * outside the router so its webviews survive route transitions, but it must + * share the same atom registry as routed UI. + */ +export function AppRoot({ router }: { readonly router: AppRouter }) { + return ( + + + + + ); +} diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts new file mode 100644 index 00000000000..e4634f5b98d --- /dev/null +++ b/apps/web/src/assets/assetUrls.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAssetUrl } from "./assetUrls"; + +describe("resolveAssetUrl", () => { + it("resolves an environment-relative asset URL", () => { + expect( + resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), + ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); + }); + + it("rejects an invalid environment base URL", () => { + expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); + }); +}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index e4fba2c5b99..673b093e333 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,89 +1,48 @@ +import { useAtomValue } from "@effect/atom-react"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useMemo, useState } from "react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useMemo } from "react"; -import { readEnvironmentApi } from "~/environmentApi"; -import { readEnvironmentConnection } from "~/environments/runtime"; +import { assetEnvironment } from "~/state/assets"; +import { usePreparedConnection } from "~/state/session"; -const REFRESH_MARGIN_MS = 30_000; +export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -interface CachedAssetUrl { - readonly url: string; - readonly expiresAt: number; -} - -const assetUrlCache = new Map(); -const assetUrlRequests = new Map>(); - -function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string { - return `${environmentId}:${JSON.stringify(resource)}`; -} - -export async function resolveAssetUrl( - environmentId: EnvironmentId, - resource: AssetResource, -): Promise { - const key = assetCacheKey(environmentId, resource); - const cached = assetUrlCache.get(key); - if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { - return cached; - } - - const inFlight = assetUrlRequests.get(key); - if (inFlight) { - return inFlight; +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const preparedConnection = usePreparedConnection(environmentId); + const result = useAtomValue( + assetEnvironment.createUrl({ + environmentId, + input: { resource }, + }), + ); + if (preparedConnection._tag === "None" || result._tag !== "Success") { + return null; } - - const request = (async () => { - const api = readEnvironmentApi(environmentId); - const connection = readEnvironmentConnection(environmentId); - if (!api || !connection) { - throw new Error("Environment is not connected."); - } - const result = await api.assets.createUrl({ resource }); - const cachedResult = { - url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(), - expiresAt: result.expiresAt, - }; - assetUrlCache.set(key, cachedResult); - return cachedResult; - })().finally(() => { - assetUrlRequests.delete(key); - }); - assetUrlRequests.set(key, request); - return request; + return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); } -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { - const resourceJson = JSON.stringify(resource); - const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]); - const key = assetCacheKey(environmentId, stableResource); - const [url, setUrl] = useState(() => assetUrlCache.get(key)?.url ?? null); - - useEffect(() => { - let cancelled = false; - let refreshTimer: ReturnType | undefined; - - const load = () => { - void resolveAssetUrl(environmentId, stableResource) - .then((result) => { - if (cancelled) return; - setUrl(result.url); - refreshTimer = setTimeout( - load, - Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS), - ); - }) - .catch(() => { - if (!cancelled) setUrl(null); - }); - }; - load(); - - return () => { - cancelled = true; - if (refreshTimer) clearTimeout(refreshTimer); - }; - }, [environmentId, key, stableResource]); - - return url; +export function useAssetUrls( + environmentId: EnvironmentId, + resources: ReadonlyArray, +): ReadonlyArray { + const preparedConnection = usePreparedConnection(environmentId); + const results = useAtomValue( + assetEnvironment.createUrls({ + environmentId, + resources, + }), + ); + return useMemo( + () => + preparedConnection._tag === "None" + ? resources.map(() => null) + : results.map((result) => + AsyncResult.isSuccess(result) + ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) + : null, + ), + [preparedConnection, resources, results], + ); } diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 6815cd70f8c..c0713bfc059 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, @@ -117,6 +115,7 @@ describe("resolveInitialServerAuthGateState", () => { disposeHttpTest = undefined; const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); __resetServerAuthBootstrapForTests(); + __setPrimaryHttpRunnerForTests(); vi.unstubAllEnvs(); vi.useRealTimers(); vi.restoreAllMocks(); @@ -220,18 +219,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 +245,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 () => { @@ -287,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => { }); 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 { isPrimaryEnvironmentRequestError, 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: "PrimaryEnvironmentRequestError", + operation: "exchange-bootstrap-credential", + status: 401, + detail: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentRequestError(error)).toBe(true); + if (!isPrimaryEnvironmentRequestError(error)) { + throw new Error("Expected a structured primary environment request 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" }]); }); diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts new file mode 100644 index 00000000000..b87276f1b9c --- /dev/null +++ b/apps/web/src/branding.logic.ts @@ -0,0 +1,34 @@ +const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function formatAppDisplayName(input: { + readonly baseName: string; + readonly stageLabel: string; +}): string { + return `${input.baseName} (${input.stageLabel})`; +} + +export function resolveServerBackedAppStageLabel(input: { + readonly primaryServerVersion: string | null | undefined; + readonly fallbackStageLabel: string; +}): string { + return input.primaryServerVersion && + NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) + ? "Nightly" + : input.fallbackStageLabel; +} + +export function resolveServerBackedAppDisplayName(input: { + readonly baseName: string; + readonly fallbackDisplayName: string; + readonly fallbackStageLabel: string; + readonly primaryServerVersion: string | null | undefined; +}): string { + const stageLabel = resolveServerBackedAppStageLabel({ + primaryServerVersion: input.primaryServerVersion, + fallbackStageLabel: input.fallbackStageLabel, + }); + + return stageLabel === input.fallbackStageLabel + ? input.fallbackDisplayName + : formatAppDisplayName({ baseName: input.baseName, stageLabel }); +} diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index d9b69bce94a..4aa969c0279 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + resolveServerBackedAppDisplayName, + resolveServerBackedAppStageLabel, +} from "./branding.logic"; const originalWindow = globalThis.window; @@ -55,3 +59,47 @@ describe("branding", () => { expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); }); }); + +describe("branding logic", () => { + it("returns Nightly for nightly primary server versions", () => { + expect( + resolveServerBackedAppStageLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); + }); + + it("updates the display name for nightly primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.28-nightly.20260616.12", + }), + ).toBe("T3 Code (Nightly)"); + }); + + it("keeps the fallback display name for stable primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.27", + }), + ).toBe("T3 Code (Alpha)"); + }); + + it("keeps the fallback display name for malformed nightly primary server versions", () => { + expect( + resolveServerBackedAppDisplayName({ + baseName: "T3 Code", + fallbackDisplayName: "T3 Code (Alpha)", + fallbackStageLabel: "Alpha", + primaryServerVersion: "0.0.28-nightly.20260616", + }), + ).toBe("T3 Code (Alpha)"); + }); +}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 5c1309ca06b..7fc57cf0d03 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,4 +1,5 @@ import type { DesktopAppBranding } from "@t3tools/contracts"; +import { formatAppDisplayName } from "./branding.logic"; function readInjectedDesktopAppBranding(): DesktopAppBranding | null { if (typeof window === "undefined") { @@ -21,5 +22,6 @@ export const APP_STAGE_LABEL = HOSTED_APP_CHANNEL_LABEL ?? (import.meta.env.DEV ? "Dev" : "Alpha"); export const APP_DISPLAY_NAME = - injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; + injectedDesktopAppBranding?.displayName ?? + formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index feac8ed0f22..205dce73583 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,11 +1,11 @@ "use client"; -import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; -import { usePreviewStateStore } from "~/previewStateStore"; +import { useActivePreviewSessions } from "~/previewStateStore"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; @@ -13,7 +13,7 @@ import { HostedBrowserWebview } from "./HostedBrowserWebview"; export function ElectronBrowserHost() { const { resolvedTheme } = useTheme(); - const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); + const previewByThreadKey = useActivePreviewSessions(); const sessions = useMemo( () => Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 276a9090af2..cdd33fa150d 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -7,9 +7,9 @@ import { useCallback, useEffect, useRef } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { useBrowserRecordingStore } from "./browserRecording"; +import { useActiveBrowserRecordingTabId } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import { acquireDesktopTab } from "./desktopTabLifetime"; +import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; interface ElectronWebview extends HTMLElement { @@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: { const { threadRef, tabId, initialUrl } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const tabLeaseRef = useRef(null); const webviewRef = useRef(null); const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); - const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + const recording = useActiveBrowserRecordingTabId() === tabId; usePreviewBridge({ threadRef, tabId }); - useEffect(() => acquireDesktopTab(tabId), [tabId]); + useEffect(() => { + const lease = acquireDesktopTab(tabId); + tabLeaseRef.current = lease; + return () => { + if (tabLeaseRef.current === lease) tabLeaseRef.current = null; + lease.release(); + }; + }, [tabId]); const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; @@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: { const webview = webviewRef.current; const bridge = previewBridge; if (!webview || !config || !bridge) return; + let disposed = false; const register = () => { - try { - const webContentsId = webview.getWebContentsId(); - if (Number.isInteger(webContentsId) && webContentsId > 0) { - void bridge.registerWebview(tabId, webContentsId); + const lease = tabLeaseRef.current; + if (!lease) return; + void (async () => { + try { + // The main-process tab and the DOM webview are created by separate + // effects. Wait for the former so registration cannot race and fail + // with PreviewTabNotFoundError on a fast about:blank attachment. + await lease.ready; + if (disposed || webviewRef.current !== webview) return; + const webContentsId = webview.getWebContentsId(); + if (Number.isInteger(webContentsId) && webContentsId > 0) { + await bridge.registerWebview(tabId, webContentsId); + } + } catch { + // did-attach/dom-ready will retry if the guest was not ready yet. } - } catch { - // A later dom-ready will retry registration. - } + })(); }; + webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); register(); - return () => webview.removeEventListener("dom-ready", register); + return () => { + disposed = true; + webview.removeEventListener("did-attach", register); + webview.removeEventListener("dom-ready", register); + }; }, [config, tabId]); if (!config) return null; diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 8a1c6f41327..2a8accd8625 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -2,9 +2,11 @@ import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, } from "@t3tools/contracts"; -import { create } from "zustand"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; interface ActiveRecording { @@ -17,21 +19,14 @@ interface ActiveRecording { readonly startedAt: string; } -interface BrowserRecordingState { - activeTabId: string | null; - startedAt: string | null; - lastArtifact: DesktopPreviewRecordingArtifact | null; - setActive: (tabId: string | null, startedAt: string | null) => void; - setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; -} +const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("preview:active-browser-recording-tab"), +); -export const useBrowserRecordingStore = create()((set) => ({ - activeTabId: null, - startedAt: null, - lastArtifact: null, - setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), - setArtifact: (lastArtifact) => set({ lastArtifact }), -})); +export function useActiveBrowserRecordingTabId(): string | null { + return useAtomValue(activeBrowserRecordingTabIdAtom); +} let active: ActiveRecording | null = null; let unsubscribeFrames: (() => void) | null = null; @@ -56,9 +51,30 @@ const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { image.src = `data:image/jpeg;base64,${frame.data}`; }; -export async function startBrowserRecording(tabId: string): Promise { +const stopMediaRecorder = async (recorder: MediaRecorder): Promise => { + if (recorder.state === "inactive") return; + const stopped = new Promise((resolve) => + recorder.addEventListener("stop", () => resolve(), { once: true }), + ); + recorder.stop(); + await stopped; +}; + +const clearActiveRecording = (recording: ActiveRecording): void => { + if (active !== recording) return; + active = null; + unsubscribeFrames?.(); + unsubscribeFrames = null; + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); +}; + +export async function startBrowserRecording(tabId: string): Promise { const bridge = previewBridge; - if (!bridge || active) return; + if (!bridge) throw new Error("Browser recording is unavailable."); + if (active) { + if (active.tabId === tabId) return active.startedAt; + throw new Error("Another preview tab is already being recorded."); + } const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, rect?.width ?? 1280); @@ -75,15 +91,17 @@ export async function startBrowserRecording(tabId: string): Promise { recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); - active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + active = recording; unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); recorder.start(1_000); try { await bridge.recording.startScreencast(tabId); - useBrowserRecordingStore.getState().setActive(tabId, startedAt); + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + return startedAt; } catch (error) { - active = null; - recorder.stop(); + await stopMediaRecorder(recorder); + clearActiveRecording(recording); throw error; } } @@ -94,22 +112,17 @@ export async function stopBrowserRecording( const bridge = previewBridge; const recording = active; if (!bridge || !recording || recording.tabId !== tabId) return null; - await bridge.recording.stopScreencast(tabId); - const stopped = new Promise((resolve) => - recording.recorder.addEventListener("stop", () => resolve(), { once: true }), - ); - recording.recorder.stop(); - await stopped; - const blob = new Blob(recording.chunks, { type: recording.mimeType }); - const artifact = await bridge.recording.save( - tabId, - recording.mimeType, - new Uint8Array(await blob.arrayBuffer()), - ); - active = null; - unsubscribeFrames?.(); - unsubscribeFrames = null; - useBrowserRecordingStore.getState().setActive(null, null); - useBrowserRecordingStore.getState().setArtifact(artifact); - return artifact; + try { + await bridge.recording.stopScreencast(tabId); + await stopMediaRecorder(recording.recorder); + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + return await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + } finally { + await stopMediaRecorder(recording.recorder); + clearActiveRecording(recording); + } } diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index a50275eb8c0..2305812784f 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -1,17 +1,15 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const readEnvironmentConnection = vi.fn(); +const readPreparedConnection = vi.fn(); -vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); +vi.mock("~/state/session", () => ({ readPreparedConnection })); describe("browser target resolver", () => { - beforeEach(() => readEnvironmentConnection.mockReset()); + beforeEach(() => readPreparedConnection.mockReset()); it("maps environment ports onto a private network host", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -28,9 +26,7 @@ describe("browser target resolver", () => { }); it("refuses public relay hosts until the authenticated gateway exists", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect(() => resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -41,9 +37,7 @@ describe("browser target resolver", () => { }); it("normalizes schemeless localhost server-picker values", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( "http://localhost:5173/", @@ -61,9 +55,7 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readEnvironmentConnection.mockReturnValue({ - knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, - }); + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 12276673002..0a6dc3aa7c2 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -5,7 +5,7 @@ import type { } from "@t3tools/contracts"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; -import { readEnvironmentConnection } from "~/environments/runtime"; +import { readPreparedConnection } from "~/state/session"; const isPrivateNetworkHost = (host: string): boolean => { const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); @@ -36,9 +36,9 @@ export function resolveBrowserNavigationTarget( environmentId, }; } - const connection = readEnvironmentConnection(environmentId); + const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); + const environmentUrl = new URL(connection.httpBaseUrl); if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts new file mode 100644 index 00000000000..1e3b1632bcc --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { closeTab, createTab } = vi.hoisted(() => ({ + closeTab: vi.fn(async () => undefined), + createTab: vi.fn<() => Promise>(), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { closeTab, createTab }, +})); + +import { acquireDesktopTab } from "./desktopTabLifetime"; + +describe("desktopTabLifetime", () => { + beforeEach(() => { + closeTab.mockClear(); + createTab.mockClear(); + }); + + it("shares tab creation readiness across concurrent leases", async () => { + let resolveCreation: (() => void) | undefined; + createTab.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreation = resolve; + }), + ); + + const first = acquireDesktopTab("tab_readiness"); + const second = acquireDesktopTab("tab_readiness"); + + expect(createTab).toHaveBeenCalledOnce(); + expect(first.ready).toBe(second.ready); + + let ready = false; + void first.ready.then(() => { + ready = true; + }); + await Promise.resolve(); + expect(ready).toBe(false); + + resolveCreation?.(); + await first.ready; + expect(ready).toBe(true); + }); +}); diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts index 4254c7e6afc..d621f6dc30c 100644 --- a/apps/web/src/browser/desktopTabLifetime.ts +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge"; interface DesktopTabLease { references: number; closeTimer: number | null; + ready: Promise; } const leases = new Map(); -export function acquireDesktopTab(tabId: string): () => void { - const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; +export interface AcquiredDesktopTab { + readonly ready: Promise; + readonly release: () => void; +} + +export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { + const current = + leases.get(tabId) ?? + ({ + references: 0, + closeTimer: null, + ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), + } satisfies DesktopTabLease); if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); current.references += 1; current.closeTimer = null; leases.set(tabId, current); - if (current.references === 1) void previewBridge?.createTab(tabId); - return () => { - const lease = leases.get(tabId); - if (!lease) return; - lease.references = Math.max(0, lease.references - 1); - if (lease.references > 0) return; - lease.closeTimer = window.setTimeout(() => { - const latest = leases.get(tabId); - if (!latest || latest.references > 0) return; - leases.delete(tabId); - void previewBridge?.closeTab(tabId); - }, 0); + return { + ready: current.ready, + release: () => { + const lease = leases.get(tabId); + if (!lease) return; + lease.references = Math.max(0, lease.references - 1); + if (lease.references > 0) return; + lease.closeTimer = window.setTimeout(() => { + const latest = leases.get(tabId); + if (!latest || latest.references > 0) return; + leases.delete(tabId); + void previewBridge?.closeTab(tabId); + }, 0); + }, }; } diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 6fcc8ec9954..b89b87c9289 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -1,36 +1,98 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + PreviewOpenInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + type AtomCommandResult, + mapAtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import { AsyncResult } from "effect/unstable/reactivity"; -import { readEnvironmentApi } from "~/environmentApi"; import { resolveAssetUrl } from "~/assets/assetUrls"; -import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { + applyPreviewServerSnapshot, + isPreviewSupportedInRuntime, + rememberPreviewUrl, +} from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); -export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - throw new Error("Environment is not connected."); - } +export class BrowserPreviewUnavailableError extends Data.TaggedError( + "BrowserPreviewUnavailableError", +)<{ + readonly message: string; +}> {} + +export type OpenPreviewMutation = (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; +}) => Promise>; - const snapshot = await api.preview.open({ threadId: threadRef.threadId, url }); - usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); - usePreviewStateStore.getState().rememberUrl(threadRef, url); - useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); +export async function openUrlInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly url: string; + readonly openPreview: OpenPreviewMutation; +}): Promise> { + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + return mapAtomCommandResult(result, (snapshot) => { + applyPreviewServerSnapshot(input.threadRef, snapshot); + rememberPreviewUrl(input.threadRef, input.url); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + }); } -export async function openFileInPreview( - threadRef: ScopedThreadRef, - filePath: string, -): Promise { +export async function openFileInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly filePath: string; + readonly httpBaseUrl: string; + readonly createAssetUrl: (input: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Promise>; + readonly openPreview: OpenPreviewMutation; +}): Promise> { if (!isPreviewSupportedInRuntime()) { - throw new Error("The integrated browser is unavailable in this runtime."); + return AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "The integrated browser is unavailable in this runtime.", + }), + ), + ); + } + const assetResult = await input.createAssetUrl({ + environmentId: input.threadRef.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: input.threadRef.threadId, + path: input.filePath, + }, + }, + }); + if (assetResult._tag === "Failure") { + return AsyncResult.failure(assetResult.cause); + } + const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl); + if (assetUrl === null) { + return AsyncResult.failure( + Cause.die(new Error("The environment returned an invalid asset URL.")), + ); } - const asset = await resolveAssetUrl(threadRef.environmentId, { - _tag: "workspace-file", - threadId: threadRef.threadId, - path: filePath, + return openUrlInPreview({ + threadRef: input.threadRef, + url: assetUrl, + openPreview: input.openPreview, }); - await openUrlInPreview(threadRef, asset.url); } diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts new file mode 100644 index 00000000000..35eb665eb7e --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + loadPreviewWebviewConfig, + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +} from "./previewWebviewConfigState"; + +const environmentId = EnvironmentId.make("environment-1"); + +describe("loadPreviewWebviewConfig", () => { + it.effect("reports a structurally distinct missing-bridge failure", () => + Effect.gen(function* () { + const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); + expect(error.environmentId).toBe(environmentId); + expect(error.message).toContain(environmentId); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("preserves the bridge rejection as the load failure cause", () => + Effect.gen(function* () { + const cause = new Error("ipc unavailable"); + const error = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: () => Promise.reject(cause), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewConfigLoadError); + expect(error.environmentId).toBe(environmentId); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain(cause.message); + }), + ); + + it.effect("forwards the environment id to the bridge", () => + Effect.gen(function* () { + let requestedEnvironmentId: EnvironmentId | null = null; + const config = { + partition: "persist:test-preview", + webPreferences: "sandbox=yes", + preloadUrl: null, + }; + const result = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: (input) => { + requestedEnvironmentId = input; + return Promise.resolve(config); + }, + }); + + expect(requestedEnvironmentId).toBe(environmentId); + expect(result).toEqual(config); + }), + ); +}); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 99a8388ec5a..6f1cf058e38 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -1,8 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; -import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; -import * as Data from "effect/Data"; +import type { + DesktopPreviewBridge, + DesktopPreviewWebviewConfig, + EnvironmentId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -10,27 +14,51 @@ import { previewBridge } from "~/components/preview/previewBridge"; const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; -class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PreviewWebviewBridgeUnavailableError extends Schema.TaggedErrorClass()( + "PreviewWebviewBridgeUnavailableError", + { environmentId: Schema.String }, +) { + override get message(): string { + return `Desktop preview configuration is unavailable for environment "${this.environmentId}".`; + } +} + +export class PreviewWebviewConfigLoadError extends Schema.TaggedErrorClass()( + "PreviewWebviewConfigLoadError", + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load desktop preview configuration for environment "${this.environmentId}".`; + } +} + +export const PreviewWebviewConfigError = Schema.Union([ + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +]); +export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type; + +type PreviewConfigBridge = Pick; + +export const loadPreviewWebviewConfig = ( + environmentId: EnvironmentId, + bridge: PreviewConfigBridge | null = previewBridge, +): Effect.Effect => { + if (bridge === null) { + return Effect.fail(new PreviewWebviewBridgeUnavailableError({ environmentId })); + } + + return Effect.tryPromise({ + try: () => bridge.getPreviewConfig(environmentId), + catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), + }); +}; const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make( - Effect.tryPromise({ - try: () => { - if (!previewBridge) { - throw new Error("Desktop preview bridge is unavailable."); - } - return previewBridge.getPreviewConfig(environmentId); - }, - catch: (cause) => - new PreviewWebviewConfigError({ - message: "Could not load desktop preview configuration.", - cause, - }), - }), - ).pipe( + Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( Atom.swr({ staleTime: PREVIEW_CONFIG_STALE_TIME_MS, revalidateOnMount: true, diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index e2cf84ccc77..6c449eea2b1 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,17 @@ afterEach(() => { }); describe("clientPersistenceStorage", () => { - it("stores browser secrets inline with the saved environment record", async () => { - const testWindow = getTestWindow(); - const { - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - readBrowserSavedEnvironmentRegistry, - readBrowserSavedEnvironmentSecret, - writeBrowserSavedEnvironmentRegistry, - writeBrowserSavedEnvironmentSecret, - } = await import("./clientPersistenceStorage"); - - writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); - expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true); - writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); - - 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", - }, - ], - }); + 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); }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 2838f502881..b6a9f1f8e03 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; @@ -80,138 +27,3 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); } - -function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument { - if (!hasWindow()) { - return {}; - } - - try { - const parsed = getLocalStorageItem( - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - BrowserSavedEnvironmentRegistryDocumentSchema, - ); - return parsed ?? {}; - } catch { - return {}; - } -} - -function writeBrowserSavedEnvironmentRegistryDocument( - document: BrowserSavedEnvironmentRegistryDocument, -): void { - if (!hasWindow()) { - return; - } - - setLocalStorageItem( - SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - document, - BrowserSavedEnvironmentRegistryDocumentSchema, - ); -} - -function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray { - return readBrowserSavedEnvironmentRegistryDocument().records ?? []; -} - -function writeBrowserSavedEnvironmentRecords( - records: ReadonlyArray, -): void { - writeBrowserSavedEnvironmentRegistryDocument({ - version: 1, - records, - }); -} - -export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray { - return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => - toPersistedSavedEnvironmentRecord(record), - ); -} - -export function writeBrowserSavedEnvironmentRegistry( - records: ReadonlyArray, -): void { - const existing = new Map( - readBrowserSavedEnvironmentRecordsWithSecrets().map( - (record) => [record.environmentId, record] as const, - ), - ); - writeBrowserSavedEnvironmentRecords( - records.map((record) => { - const bearerToken = existing.get(record.environmentId)?.bearerToken; - return bearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - bearerToken, - } - : toPersistedSavedEnvironmentRecord(record); - }), - ); -} - -export function readBrowserSavedEnvironmentSecret( - environmentId: EnvironmentIdValue, -): string | null { - return ( - readBrowserSavedEnvironmentRecordsWithSecrets().find( - (record) => record.environmentId === environmentId, - )?.bearerToken ?? null - ); -} - -export function writeBrowserSavedEnvironmentSecret( - environmentId: EnvironmentIdValue, - secret: string, -): boolean { - const document = readBrowserSavedEnvironmentRegistryDocument(); - const records = document.records ?? []; - let found = false; - writeBrowserSavedEnvironmentRegistryDocument({ - version: document.version ?? 1, - // The persistence update is copy-on-write so storage subscribers observe a new document. - // oxlint-disable-next-line oxc/no-map-spread - records: records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - found = true; - const nextRecord: BrowserSavedEnvironmentRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - bearerToken: secret, - }; - return { - ...nextRecord, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; - }), - }); - return found; -} - -export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { - const document = readBrowserSavedEnvironmentRegistryDocument(); - writeBrowserSavedEnvironmentRegistryDocument({ - version: document.version ?? 1, - records: (document.records ?? []).map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), - }); -} diff --git a/apps/web/src/cloud/desktopAuth.test.ts b/apps/web/src/cloud/desktopAuth.test.ts deleted file mode 100644 index 520130518d5..00000000000 --- a/apps/web/src/cloud/desktopAuth.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth"; - -describe("resolveDesktopCloudAuthOAuthOptions", () => { - it("ignores absent social provider settings", () => { - expect( - resolveDesktopCloudAuthOAuthOptions({ - environment: { - userSettings: { - social: { - github: null, - google: { - strategy: "oauth_google", - enabled: true, - authenticatable: true, - }, - }, - }, - }, - }), - ).toEqual([ - { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: null, - }, - ]); - }); - - it("preserves provider display metadata when Clerk exposes the strategy list", () => { - expect( - resolveDesktopCloudAuthOAuthOptions({ - environment: { - userSettings: { - authenticatableSocialStrategies: ["oauth_google"], - social: { - oauth_google: { - strategy: "oauth_google", - enabled: true, - authenticatable: true, - name: "Google", - logo_url: "https://img.clerk.com/static/google.png", - }, - }, - }, - }, - }), - ).toEqual([ - { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: "https://img.clerk.com/static/google.png", - }, - ]); - }); -}); diff --git a/apps/web/src/cloud/desktopAuth.ts b/apps/web/src/cloud/desktopAuth.ts deleted file mode 100644 index 0e2a328c30e..00000000000 --- a/apps/web/src/cloud/desktopAuth.ts +++ /dev/null @@ -1,144 +0,0 @@ -export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; - -export interface DesktopCloudAuthOAuthOption { - readonly strategy: DesktopCloudAuthOAuthStrategy; - readonly label: string; - readonly providerId: string; - readonly iconUrl: string | null; -} - -interface ClerkOAuthProviderSetting { - readonly enabled?: unknown; - readonly authenticatable?: unknown; - readonly strategy?: unknown; - readonly name?: unknown; - readonly logo_url?: unknown; -} - -interface ClerkUserSettingsLike { - readonly authenticatableSocialStrategies?: unknown; - readonly social?: unknown; -} - -interface ClerkEnvironmentLike { - readonly userSettings?: ClerkUserSettingsLike; -} - -interface ClerkLike { - readonly __internal_environment?: ClerkEnvironmentLike; - readonly environment?: ClerkEnvironmentLike; -} - -const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting => - typeof value === "object" && value !== null; - -const OAUTH_LABELS: Readonly> = { - oauth_apple: "Apple", - oauth_discord: "Discord", - oauth_github: "GitHub", - oauth_gitlab: "GitLab", - oauth_google: "Google", - oauth_linear: "Linear", - oauth_microsoft: "Microsoft", - oauth_slack: "Slack", - oauth_x: "X", -}; - -// Mirrors Clerk UI's enabled-provider projection for the local desktop replacement: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/hooks/useEnabledThirdPartyProviders.tsx -export function isDesktopCloudAuthOAuthStrategy( - value: unknown, -): value is DesktopCloudAuthOAuthStrategy { - return typeof value === "string" && value.startsWith("oauth_"); -} - -export function getDesktopCloudAuthOAuthStrategyLabel( - strategy: DesktopCloudAuthOAuthStrategy, -): string { - const mapped = OAUTH_LABELS[strategy]; - if (mapped) return mapped; - return strategy - .replace(/^oauth_custom_/, "") - .replace(/^oauth_/, "") - .split("_") - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export function resolveDesktopCloudAuthOAuthOptions( - clerk: unknown, -): readonly DesktopCloudAuthOAuthOption[] { - const environment = - (clerk as ClerkLike | null | undefined)?.__internal_environment ?? - (clerk as ClerkLike | null | undefined)?.environment; - const userSettings = environment?.userSettings; - const strategies = userSettings?.authenticatableSocialStrategies; - if (Array.isArray(strategies)) { - return uniqueOptions( - strategies - .filter(isDesktopCloudAuthOAuthStrategy) - .map((strategy) => - createOAuthOption(strategy, findProviderSetting(userSettings, strategy)), - ), - ); - } - - const social = userSettings?.social; - if (!social || typeof social !== "object") { - return []; - } - - return uniqueOptions( - Object.values(social as Record) - .filter(isClerkOAuthProviderSetting) - .filter((provider) => provider.enabled !== false && provider.authenticatable !== false) - .map((provider) => { - const strategy = isDesktopCloudAuthOAuthStrategy(provider.strategy) - ? provider.strategy - : null; - if (!strategy) return null; - return createOAuthOption(strategy, provider); - }) - .filter((option): option is DesktopCloudAuthOAuthOption => option !== null), - ); -} - -function findProviderSetting( - userSettings: ClerkUserSettingsLike | undefined, - strategy: DesktopCloudAuthOAuthStrategy, -): ClerkOAuthProviderSetting | undefined { - const social = userSettings?.social; - if (!social || typeof social !== "object") return undefined; - - return Object.values(social as Record) - .filter(isClerkOAuthProviderSetting) - .find((provider) => provider.strategy === strategy); -} - -function createOAuthOption( - strategy: DesktopCloudAuthOAuthStrategy, - provider?: ClerkOAuthProviderSetting, -): DesktopCloudAuthOAuthOption { - return { - strategy, - label: - typeof provider?.name === "string" && provider.name.trim() - ? provider.name - : getDesktopCloudAuthOAuthStrategyLabel(strategy), - providerId: strategy.replace(/^oauth_/, ""), - iconUrl: - typeof provider?.logo_url === "string" && provider.logo_url.trim() ? provider.logo_url : null, - }; -} - -function uniqueOptions( - options: readonly DesktopCloudAuthOAuthOption[], -): readonly DesktopCloudAuthOAuthOption[] { - const seen = new Set(); - return options.filter((option) => { - if (seen.has(option.strategy)) return false; - seen.add(option.strategy); - return true; - }); -} diff --git a/apps/web/src/cloud/desktopClerk.tsx b/apps/web/src/cloud/desktopClerk.tsx deleted file mode 100644 index 68179f5cf03..00000000000 --- a/apps/web/src/cloud/desktopClerk.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { Clerk } from "@clerk/clerk-js"; -import { - buildClerkUIScriptAttributes, - clerkUIScriptUrl, - InternalClerkProvider, -} from "@clerk/react/internal"; -import type { ClerkProviderProps } from "@clerk/react"; -import { - clerkFrontendApiHostnameFromPublishableKey, - isAllowedClerkFrontendApiHostname, -} from "@t3tools/shared/relayAuth"; -import React, { useEffect, useState } from "react"; - -import { - makeDesktopClerkExternalAccountAdapter, - type DesktopClerkUser, -} from "./desktopClerkExternalAccounts"; - -type DesktopClerkUiCtor = NonNullable; - -interface ClerkFrontendApiRequest { - credentials?: RequestCredentials; - headers?: Headers; - url?: URL; -} - -interface ClerkFrontendApiResponse { - headers: Headers; - payload?: { - errors?: readonly { - code?: string; - }[]; - }; -} - -interface NativeRequestClerk { - readonly publishableKey?: string; - __internal_onBeforeRequest?: ( - listener: (request: ClerkFrontendApiRequest) => void | Promise, - ) => void; - __internal_onAfterResponse?: ( - listener: ( - request: ClerkFrontendApiRequest, - response?: ClerkFrontendApiResponse, - ) => void | Promise, - ) => void; - __unstable__onBeforeRequest?: ( - listener: (request: ClerkFrontendApiRequest) => void | Promise, - ) => void; - __unstable__onAfterResponse?: ( - listener: ( - request: ClerkFrontendApiRequest, - response?: ClerkFrontendApiResponse, - ) => void | Promise, - ) => void; -} - -interface DesktopClerkProviderProps { - readonly children: React.ReactNode; - readonly publishableKey: string; -} - -let desktopClerk: Clerk | null = null; -let desktopClerkFetchInstalled = false; -let desktopClerkUiLoad: Promise | null = null; -let desktopClerkFrontendApiHostname: string | null = null; -let desktopClerkExternalAccountCleanup: (() => void) | null = null; - -const isNativeRequestClerk = (value: unknown): value is NativeRequestClerk => { - if (typeof value !== "object" || value === null) return false; - const candidate = value as { - __internal_onBeforeRequest?: unknown; - __internal_onAfterResponse?: unknown; - __unstable__onBeforeRequest?: unknown; - __unstable__onAfterResponse?: unknown; - }; - return ( - (typeof candidate.__internal_onBeforeRequest === "function" || - typeof candidate.__unstable__onBeforeRequest === "function") && - (typeof candidate.__internal_onAfterResponse === "function" || - typeof candidate.__unstable__onAfterResponse === "function") - ); -}; - -const getStoredClientJwt = (): Promise => - window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null); - -const setStoredClientJwt = (token: string): Promise => - window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false); - -const clearStoredClientJwt = (): Promise => - window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve(); - -const isClerkFrontendApiUrl = (url: URL): boolean => - url.protocol === "https:" && - isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname); - -const headersToRecord = (headers: Headers): Record => { - const record: Record = {}; - headers.forEach((value, key) => { - record[key] = value; - }); - return record; -}; - -function installDesktopClerkFetchProxy(publishableKey: string): void { - desktopClerkFrontendApiHostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); - if (desktopClerkFetchInstalled) return; - const bridge = window.desktopBridge; - if (!bridge) return; - - const browserFetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const request = new Request(input, init); - const url = new URL(request.url); - if (!isClerkFrontendApiUrl(url)) { - return browserFetch(input, init); - } - - const body = - request.method === "GET" || request.method === "HEAD" - ? undefined - : await request.clone().text(); - const result = await bridge.fetchCloudAuth({ - url: request.url, - method: request.method, - headers: headersToRecord(request.headers), - ...(body === undefined ? {} : { body }), - }); - - return new Response(result.body, { - status: result.status, - statusText: result.statusText, - headers: result.headers, - }); - }; - desktopClerkFetchInstalled = true; -} - -function installDesktopClerkExternalAccounts(clerk: Clerk): void { - desktopClerkExternalAccountCleanup?.(); - desktopClerkExternalAccountCleanup = null; - - const bridge = window.desktopBridge; - if (!bridge) return; - - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - const unsubscribe = clerk.addListener(({ user }) => { - if (user) { - adapter.installUser(user as DesktopClerkUser); - } - }); - desktopClerkExternalAccountCleanup = () => { - unsubscribe(); - adapter.dispose(); - }; -} - -function loadDesktopClerkUi(publishableKey: string): Promise { - if (window.__internal_ClerkUICtor) { - return Promise.resolve(window.__internal_ClerkUICtor); - } - if (desktopClerkUiLoad) { - return desktopClerkUiLoad; - } - - const load = new Promise((resolve, reject) => { - const scriptUrl = clerkUIScriptUrl({ publishableKey }); - const existingScript = document.querySelector( - "script[data-clerk-ui-script]", - ); - - const resolveLoadedUi = () => { - const ClerkUI = window.__internal_ClerkUICtor; - if (ClerkUI) { - resolve(ClerkUI); - return true; - } - return false; - }; - if (resolveLoadedUi()) { - return; - } - - const script = existingScript ?? document.createElement("script"); - script.async = true; - script.crossOrigin = "anonymous"; - script.src = scriptUrl; - script.dataset.clerkUiScript = "true"; - const attributes = buildClerkUIScriptAttributes({ publishableKey }); - for (const [name, value] of Object.entries(attributes)) { - script.setAttribute(name, value); - } - - const timeoutId = window.setTimeout(() => { - reject(new Error("Timed out loading Clerk UI for desktop auth.")); - }, 15_000); - script.addEventListener("load", () => { - window.clearTimeout(timeoutId); - if (!resolveLoadedUi()) { - reject(new Error("Clerk UI loaded without exposing the UI constructor.")); - } - }); - script.addEventListener("error", () => { - window.clearTimeout(timeoutId); - reject(new Error("Failed to load Clerk UI for desktop auth.")); - }); - if (!existingScript) { - document.head.append(script); - } - }).catch((error: unknown) => { - desktopClerkUiLoad = null; - throw error; - }); - - desktopClerkUiLoad = load; - return load; -} - -function getDesktopClerkInstance(publishableKey: string): Clerk { - installDesktopClerkFetchProxy(publishableKey); - - const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; - if (hasKeyChanged) { - void clearStoredClientJwt(); - desktopClerkExternalAccountCleanup?.(); - desktopClerkExternalAccountCleanup = null; - desktopClerk = null; - } - - if (desktopClerk !== null) { - return desktopClerk; - } - - const nextClerk = new Clerk(publishableKey); - installDesktopClerkExternalAccounts(nextClerk); - if (!isNativeRequestClerk(nextClerk)) { - desktopClerk = nextClerk; - return nextClerk; - } - - const onBeforeRequest = - nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest; - const onAfterResponse = - nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse; - - // Keep this aligned with Clerk Expo's native FAPI adapter: - // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/provider/singleton/createClerkInstance.ts - onBeforeRequest(async (request) => { - request.credentials = "omit"; - request.url?.searchParams.append("_is_native", "1"); - const headers = new Headers(request.headers); - - const clientJwt = await getStoredClientJwt(); - headers.set("authorization", clientJwt ?? ""); - headers.set("x-mobile", "1"); - request.headers = headers; - }); - - onAfterResponse(async (_request, response) => { - const clientJwt = response?.headers.get("authorization"); - if (clientJwt) { - await setStoredClientJwt(clientJwt); - } - - const errorCode = response?.payload?.errors?.[0]?.code; - if (errorCode === "native_api_disabled") { - console.error( - "Clerk Native API is disabled. Enable Native applications in the Clerk dashboard for desktop sign-in.", - ); - } - }); - - desktopClerk = nextClerk; - return nextClerk; -} - -export function DesktopClerkProvider({ children, publishableKey }: DesktopClerkProviderProps) { - const [clerkUiCtor, setClerkUiCtor] = useState( - () => window.__internal_ClerkUICtor, - ); - const [clerkUiError, setClerkUiError] = useState(null); - - useEffect(() => { - let isCurrent = true; - void loadDesktopClerkUi(publishableKey).then( - (ClerkUI) => { - if (isCurrent) { - setClerkUiCtor(() => ClerkUI); - } - }, - (error: unknown) => { - if (isCurrent) { - setClerkUiError(error); - } - }, - ); - return () => { - isCurrent = false; - }; - }, [publishableKey]); - - if (!clerkUiCtor) { - if (clerkUiError) { - console.error("Failed to load Clerk UI for desktop auth.", clerkUiError); - } - return null; - } - - const clerk = getDesktopClerkInstance(publishableKey); - return ( - - {children} - - ); -} diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts deleted file mode 100644 index 031094b7a00..00000000000 --- a/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - makeDesktopClerkExternalAccountAdapter, - type DesktopClerkUser, -} from "./desktopClerkExternalAccounts"; - -describe("desktop Clerk external account adapter", () => { - it("replaces renderer redirects with native callbacks and reloads the user on return", async () => { - const callbacks: ((rawUrl: string) => void)[] = []; - const callbackCleanup = vi.fn(); - const bridge = { - createCloudAuthRequest: vi - .fn() - .mockResolvedValueOnce("t3code://auth/callback?t3_state=add") - .mockResolvedValueOnce("t3code://auth/callback?t3_state=reconnect"), - onCloudAuthCallback: vi.fn((listener: (rawUrl: string) => void) => { - callbacks.push(listener); - return callbackCleanup; - }), - }; - const reauthorize = vi.fn(async (_params: Record) => account); - const account = { reauthorize }; - const createExternalAccount = vi.fn(async (_params: Record) => account); - const reload = vi.fn(async () => undefined); - const user = { - externalAccounts: [], - createExternalAccount, - reload, - } satisfies DesktopClerkUser; - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - adapter.installUser(user); - - await user.createExternalAccount({ - redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", - strategy: "oauth_microsoft", - }); - - expect(createExternalAccount).toHaveBeenCalledWith({ - redirectUrl: "t3code://auth/callback?t3_state=add", - strategy: "oauth_microsoft", - }); - - callbacks[0]?.("t3code://auth/callback?t3_state=add"); - await Promise.resolve(); - expect(reload).toHaveBeenCalledOnce(); - - await account.reauthorize({ - redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", - }); - expect(reauthorize).toHaveBeenCalledWith({ - redirectUrl: "t3code://auth/callback?t3_state=reconnect", - }); - }); - - it("cleans up the pending callback when Clerk rejects account creation", async () => { - const callbackCleanup = vi.fn(); - const bridge = { - createCloudAuthRequest: vi.fn().mockResolvedValue("t3code://auth/callback?t3_state=failed"), - onCloudAuthCallback: vi.fn(() => callbackCleanup), - }; - const createError = new Error("oauth provider unavailable"); - const user = { - externalAccounts: [], - createExternalAccount: vi.fn(async (_params: Record) => { - throw createError; - }), - reload: vi.fn(async () => undefined), - } satisfies DesktopClerkUser; - const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); - adapter.installUser(user); - - await expect(user.createExternalAccount({ strategy: "oauth_microsoft" })).rejects.toBe( - createError, - ); - expect(callbackCleanup).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.ts deleted file mode 100644 index 01ff8603e25..00000000000 --- a/apps/web/src/cloud/desktopClerkExternalAccounts.ts +++ /dev/null @@ -1,112 +0,0 @@ -interface DesktopClerkExternalAccountParams { - readonly redirectUrl?: string; - readonly [key: string]: unknown; -} - -interface DesktopClerkExternalAccount { - reauthorize: (params: DesktopClerkExternalAccountParams) => Promise; -} - -interface DesktopClerkUser { - readonly externalAccounts: readonly DesktopClerkExternalAccount[]; - createExternalAccount: ( - params: DesktopClerkExternalAccountParams, - ) => Promise; - reload: () => Promise; -} - -interface DesktopClerkExternalAccountBridge { - readonly createCloudAuthRequest: () => Promise; - readonly onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; -} - -interface DesktopClerkExternalAccountAdapter { - readonly dispose: () => void; - readonly installUser: (user: DesktopClerkUser) => void; -} - -interface MakeDesktopClerkExternalAccountAdapterInput { - readonly bridge: DesktopClerkExternalAccountBridge; - readonly reportError?: (message: string, error: unknown) => void; -} - -// Clerk's profile component uses window.location.href as the OAuth callback and navigates the -// current window to the provider. Keep the upstream component intact while adapting its resource -// calls to the native callback bridge: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx -export function makeDesktopClerkExternalAccountAdapter({ - bridge, - reportError = console.error, -}: MakeDesktopClerkExternalAccountAdapterInput): DesktopClerkExternalAccountAdapter { - const installedAccounts = new WeakSet(); - const installedUsers = new WeakSet(); - let callbackGeneration = 0; - let callbackCleanup: (() => void) | null = null; - - const clearCallback = () => { - callbackGeneration += 1; - callbackCleanup?.(); - callbackCleanup = null; - }; - - const createRedirectUrl = async (user: DesktopClerkUser): Promise => { - clearCallback(); - const redirectUrl = await bridge.createCloudAuthRequest(); - const generation = callbackGeneration; - callbackCleanup = bridge.onCloudAuthCallback(() => { - if (generation !== callbackGeneration) return; - clearCallback(); - void user.reload().catch((error: unknown) => { - reportError("Failed to reload Clerk after desktop account linking.", error); - }); - }); - return redirectUrl; - }; - - const installAccount = (user: DesktopClerkUser, account: DesktopClerkExternalAccount): void => { - if (installedAccounts.has(account)) return; - installedAccounts.add(account); - - const reauthorize = account.reauthorize.bind(account); - account.reauthorize = async (params) => { - const redirectUrl = await createRedirectUrl(user); - try { - const nextAccount = await reauthorize({ ...params, redirectUrl }); - installAccount(user, nextAccount); - return nextAccount; - } catch (error) { - clearCallback(); - throw error; - } - }; - }; - - const installUser = (user: DesktopClerkUser): void => { - for (const account of user.externalAccounts) { - installAccount(user, account); - } - if (installedUsers.has(user)) return; - installedUsers.add(user); - - const createExternalAccount = user.createExternalAccount.bind(user); - user.createExternalAccount = async (params) => { - const redirectUrl = await createRedirectUrl(user); - try { - const account = await createExternalAccount({ ...params, redirectUrl }); - installAccount(user, account); - return account; - } catch (error) { - clearCallback(); - throw error; - } - }; - }; - - return { - dispose: clearCallback, - installUser, - }; -} - -export type { DesktopClerkExternalAccountAdapter, DesktopClerkUser }; diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 754930d0ced..75951db1baf 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,32 +1,35 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { decodeJwt } from "jose"; +import { vi } from "vite-plus/test"; import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; describe("browser DPoP proofs", () => { - it("signs relay resource proofs with an access-token hash", async () => { - vi.stubGlobal("indexedDB", undefined); - const issuedAt = Math.floor(Date.now() / 1_000); - const proofKey = await Effect.runPromise(generateBrowserDpopKey); - const proof = await Effect.runPromise( - createBrowserDpopProof({ + it.effect("signs relay resource proofs with an access-token hash", () => + Effect.gen(function* () { + vi.stubGlobal("indexedDB", undefined); + const proofKey = yield* generateBrowserDpopKey; + const proof = yield* createBrowserDpopProof({ method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", accessToken: "relay-access-token", proofKey, - }).pipe(Effect.provide(browserCryptoLayer)), - ); + }).pipe(Effect.provide(browserCryptoLayer)); + const issuedAt = decodeJwt(proof.proof).iat; + expect(issuedAt).toBeTypeOf("number"); - expect( - verifyDpopProof({ - proof: proof.proof, - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect", - expectedThumbprint: proof.thumbprint, - expectedAccessToken: "relay-access-token", - nowEpochSeconds: issuedAt, - }), - ).toMatchObject({ ok: true }); - }); + expect( + verifyDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + expectedThumbprint: proof.thumbprint, + expectedAccessToken: "relay-access-token", + nowEpochSeconds: issuedAt!, + }), + ).toMatchObject({ ok: true }); + }), + ); }); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 79b439f6109..d0994955db1 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -107,43 +107,40 @@ export function writeStoredBrowserDpopKey( ); } -export const generateBrowserDpopKey: Effect.Effect = Effect.gen( - function* () { - const generated = yield* Effect.tryPromise({ - try: () => - crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ - "sign", - "verify", - ]) as Promise, - catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), - }); - const privateJwk = yield* Effect.tryPromise({ - try: () => crypto.subtle.exportKey("jwk", generated.privateKey), - catch: (cause) => dpopError("Could not export DPoP private key.", cause), - }); - const publicJwk = yield* Effect.tryPromise({ - try: () => crypto.subtle.exportKey("jwk", generated.publicKey), - catch: (cause) => dpopError("Could not export DPoP public key.", cause), - }).pipe( - Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), - Effect.mapError((cause) => - cause instanceof BrowserDpopError - ? cause - : dpopError("Generated DPoP public key is invalid.", cause), - ), - ); - const privateKey = yield* Effect.tryPromise({ - try: () => - importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - catch: (cause) => dpopError("Could not import DPoP private key.", cause), - }); - return { - privateKey, - publicJwk, - thumbprint: computeDpopJwkThumbprint(publicJwk), - }; - }, -); +export const generateBrowserDpopKey = Effect.gen(function* () { + const generated = yield* Effect.tryPromise({ + try: () => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]) as Promise, + catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + }); + const privateJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.privateKey), + catch: (cause) => dpopError("Could not export DPoP private key.", cause), + }); + const publicJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.publicKey), + catch: (cause) => dpopError("Could not export DPoP public key.", cause), + }).pipe( + Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), + Effect.mapError((cause) => + cause instanceof BrowserDpopError + ? cause + : dpopError("Generated DPoP public key is invalid.", cause), + ), + ); + const privateKey = yield* Effect.tryPromise({ + try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, + catch: (cause) => dpopError("Could not import DPoP private key.", cause), + }); + return { + privateKey, + publicJwk, + thumbprint: computeDpopJwkThumbprint(publicJwk), + }; +}); export function createBrowserDpopProof(input: { readonly method: string; diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 30cb596781a..f823016ddf0 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,911 +1,351 @@ -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, } 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", - }); - const traceparents = fetchMock.mock.calls.map( - (call) => call[1]?.headers.traceparent as string | undefined, - ); - expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); - expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); - expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( - traceparents[0]?.split("-")[1], - ); - }), - ); - - it.effect("rejects a stored managed connection for another relay origin", () => - Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - - const error = yield* withCloudServices( - connectManagedCloudEnvironment({ - clerkToken: "clerk-token", - environment, - relayUrl: "https://old-relay.example.test", + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - message: "The saved environment is linked through a different configured relay.", - }); - }), - ); - - it.effect("rejects malformed local environment link proofs", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json({ - payload: { - environmentId: "env-1", - }, - signature: "signature-1", - }), - ), ); + vi.stubGlobal("fetch", fetchMock); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Could not obtain environment link proof.", - }); - }), - ); - - it.effect("preserves typed local environment failures while obtaining a link proof", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json( - { - _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", - }, - { status: 401 }, - ), - ), - ); + const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", + expect(Option.fromNullishOr(state)).toEqual( + Option.some({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, }), - ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); - expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", ); - }), - ); - - it.effect("rejects malformed relay environment link responses", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ), + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-state", ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/client/environment-links failed", - }); }), ); - it.effect( - "links the primary local environment through the relay using the owner cookie session", - () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( - (path: string) => `http://127.0.0.1:3000${path}`, - ); - - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-proof", - ); - expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - challenge: "link-challenge", - endpoint: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - providerKind: "cloudflare_tunnel", - }, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: 3000, - }, - }); - - expect(String(fetchMock.mock.calls[2]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links", - ); - expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); - expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ - proof: validProof(), - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }); - - expect(String(fetchMock.mock.calls[3]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/relay-config", - ); - expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - }); - }), - ); - - it.effect("reads the primary local cloud link state with the owner cookie session", () => + it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ linked: true, - cloudUserId: "user_123", + cloudUserId: "user-1", relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", + relayIssuer: "https://relay.example.test", publishAgentActivity: false, }), ); vi.stubGlobal("fetch", fetchMock); - - const state = yield* withCloudServices(readPrimaryCloudLinkState()); - expect(state).toEqual({ - linked: true, - cloudUserId: "user_123", - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - publishAgentActivity: false, - }); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-state", - ); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "GET", - credentials: "include", + vi.stubGlobal("window", { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, }); - }), - ); - it.effect("clears local relay credentials before revoking the primary cloud link", () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ ok: true })); - vi.stubGlobal("fetch", fetchMock); + yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - }); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links/env-1", - ); - expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).not.toBe("include"); + expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); }), ); - it.effect("still clears local relay credentials when relay revocation fails", () => + it.effect("links an available primary environment without invoking installation", () => 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" } }), + Response.json({ + challenge: "challenge", + expiresAt: "2026-06-06T00:05:00.000Z", + }), ) - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", - }), - ); - - 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", - }), - ).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 }, - ), - ), - ); - - 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).", - }); - }), - ); - - it.effect("rejects relay credentials for a different environment", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce(Response.json("signed-proof")) .mockResolvedValueOnce( Response.json({ ok: true, - environmentId: "env-2", + environmentId: TARGET.environmentId, endpoint: { httpBaseUrl: "https://desktop.example.test", wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", + relayIssuer: "https://relay.example.test", + cloudUserId: "user-1", + environmentCredential: "environment-credential", + cloudMintPublicKey: "public-key", }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), ); vi.stubGlobal("fetch", fetchMock); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + ); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-proof", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + challenge: "challenge", + endpoint: { + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + }, }); - expect(fetchMock).toHaveBeenCalledTimes(3); }), ); - it.effect("rejects relay credentials for a different managed endpoint provider", () => + it.effect("installs a missing relay client before linking", () => Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "manual", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ); - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), + { + status: { status: "available", version: "2026.6.0" }, + installEvents: [], + }, ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); }), ); - it.effect("passes the relay issuer from the link response into local relay config", () => + it.effect("unlinks locally before revoking the relay record", () => Effect.gen(function* () { const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) .mockResolvedValueOnce( Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); + ) + .mockResolvedValueOnce(Response.json({ ok: true })); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, clerkToken: "clerk-token", }), ); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain( + `/v1/client/environment-links/${TARGET.environmentId}`, + ); }), ); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 4c94ab41660..20bf75c7d6d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,9 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -11,38 +13,25 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, EnvironmentId, + WS_METHODS, } from "@t3tools/contracts"; import { - RelayEnvironmentConnectScope, type RelayClientDeviceRecord, - type RelayEnvironmentLinkResponse, - RelayProtectedError, type RelayClientEnvironmentRecord, + type RelayEnvironmentLinkResponse, type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, - ManagedRelayClient, - ManagedRelayDpopSigner, - type WsRpcClient, -} from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { request, runStream } from "@t3tools/client-runtime/rpc"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import { ensureLocalApi } from "../localApi"; -import { - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - type SavedEnvironmentRecord, -} from "../environments/runtime"; import { readPrimaryEnvironmentDescriptor, readPrimaryEnvironmentTarget, - resolvePrimaryEnvironmentHttpUrl, } from "../environments/primary"; -import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { resolveCloudPublicConfig } from "./publicConfig"; import { finishRelayClientInstall, @@ -65,6 +54,7 @@ function relayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} const relayClientRpcError = (message: string) => (cause: unknown) => @@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) => }); function ensureRelayClientAvailable( - client: WsRpcClient, -): Effect.Effect { + environmentId: EnvironmentId, +): Effect.Effect { return Effect.gen(function* () { - const status = yield* Effect.tryPromise({ - try: () => client.cloud.getRelayClientStatus(), - catch: relayClientRpcError("Could not check relay client availability."), - }); + const registry = yield* EnvironmentRegistry; + const status = yield* registry + .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability."))); if (status.status === "available") return; if (status.status === "unsupported") { return yield* new CloudEnvironmentLinkError({ @@ -98,22 +88,35 @@ function ensureRelayClientAvailable( }); } - const installed = yield* Effect.tryPromise({ - try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), - catch: relayClientRpcError("Could not install the relay client."), - }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); - if (installed.status !== "available") { + const installed = yield* registry + .runStream( + environmentId, + runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))), + ), + ) + .pipe( + Stream.runLast, + Effect.mapError(relayClientRpcError("Could not install the relay client.")), + Effect.ensuring(Effect.sync(finishRelayClientInstall)), + ); + if (Option.isNone(installed) || installed.value.type !== "complete") { + return yield* new CloudEnvironmentLinkError({ + message: "The relay client install completed without a final status.", + }); + } + const installedStatus = installed.value.status; + if (installedStatus.status !== "available") { return yield* new CloudEnvironmentLinkError({ message: - installed.status === "unsupported" - ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` + installedStatus.status === "unsupported" + ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.` : "The relay client is still unavailable after installation.", }); } }); } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -156,31 +159,24 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(traceId ? { traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -239,16 +235,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export interface CloudManagedConnection { - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -} - export function collectCloudLinkTargets(input: { readonly primary: CloudLinkTarget | null; readonly saved: ReadonlyArray; @@ -284,7 +270,7 @@ export function listManagedCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -293,7 +279,7 @@ export function listManagedCloudEnvironments(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ clerkToken: input.clerkToken, @@ -315,7 +301,7 @@ export function listCloudDevices(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!relayUrl()) { @@ -323,7 +309,7 @@ export function listCloudDevices(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( Effect.mapError( (cause) => @@ -336,181 +322,55 @@ export function listCloudDevices(input: { }); } -export function connectManagedCloudEnvironment(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; - readonly relayUrl?: string; -}): Effect.Effect< - CloudManagedConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { +export function readPrimaryCloudLinkState(input: { + readonly target: CloudLinkTarget; +}): Effect.Effect { return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); - if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "The saved environment is linked through a different configured relay.", - }); - } - const relayClient = yield* ManagedRelayClient; - const connected = yield* relayClient - .connectEnvironment({ - clerkToken: input.clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: input.environment.environmentId, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not connect to relay-managed environment.", - cause, - }), - ), - ); - if (connected.environmentId !== input.environment.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if ( - connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || - connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || - connected.endpoint.providerKind !== input.environment.endpoint.providerKind - ) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", - }); - } - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not read connected environment descriptor.", - cause, - }), - ), - ); - if (descriptor.environmentId !== connected.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint does not match the selected environment.", - }); - } - const signer = yield* ManagedRelayDpopSigner; - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not create environment DPoP proof.", - cause, - }), - ), - ); - const session = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - credential: connected.credential, - dpopProof: bootstrapProof, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not authorize managed environment.", - cause, - }), - ), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: connected.endpoint.httpBaseUrl, - wsBaseUrl: connected.endpoint.wsBaseUrl, - relayUrl: configuredRelayUrl, - accessToken: session.access_token, - relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)), - }; - }).pipe( - Effect.withSpan("relay.environment.connect", { - root: true, - attributes: { "relay.environment_id": input.environment.environmentId }, - }), - withRelayClientTracing, - ); -} - -export function readPrimaryCloudLinkState(): Effect.Effect< - CloudLinkState | null, - CloudEnvironmentLinkError, - HttpClient.HttpClient -> { - return Effect.gen(function* () { - if (!readPrimaryCloudLinkTarget()) { - return null; - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not read environment cloud link state.")), - ); - }); + .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state."))); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function updatePrimaryCloudPreferences(input: { + readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean; }): Effect.Effect { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .preferences({ headers: {}, payload: input, }) .pipe( - withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), ); - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function unlinkPrimaryEnvironmentFromCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string | null; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* client.connect .unlink({ headers: {} }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), - ); + .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(target.environmentId), + environmentId: EnvironmentId.make(input.target.environmentId), }) .pipe( Effect.catch((cause) => @@ -520,118 +380,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { ), ); } - }); -} - -export function linkEnvironmentToCloud(input: { - readonly environment: SavedEnvironmentRecord; - readonly clerkToken: string; -}): Effect.Effect { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelayClient; - const bearerToken = yield* Effect.tryPromise({ - try: () => - ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), - catch: (cause) => - new CloudEnvironmentLinkError({ - message: `Could not read saved bearer token for ${input.environment.label}.`, - cause, - }), - }); - if (!bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: `No saved bearer token for ${input.environment.label}.`, - }); - } - - const connection = readEnvironmentConnection(input.environment.environmentId); - if (!connection) { - return yield* new CloudEnvironmentLinkError({ - message: `${input.environment.label} is not connected.`, - }); - } - yield* ensureRelayClientAvailable(connection.client); - - const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); - const headers = { authorization: `Bearer ${bearerToken}` }; - - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), - ), - ); - const proof = yield* environmentClient.connect - .linkProof({ - headers, - payload: { - challenge: challenge.challenge, - relayIssuer: configuredRelayUrl, - endpoint: { - httpBaseUrl: input.environment.httpBaseUrl, - wsBaseUrl: input.environment.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.environment.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), - ), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.environment.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, - }); - - yield* environmentClient.connect - .relayConfig({ - headers, - payload: { - relayUrl: configuredRelayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function linkPrimaryEnvironmentToCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { @@ -639,15 +398,9 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); - yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -672,17 +425,14 @@ export function linkPrimaryEnvironmentToCloud(input: { challenge: challenge.challenge, relayIssuer: configuredRelayUrl, endpoint: { - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, + httpBaseUrl: input.target.httpBaseUrl, + wsBaseUrl: input.target.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(target.httpBaseUrl), + origin: endpointOrigin(input.target.httpBaseUrl), }, }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not obtain environment link proof.")), - ); + .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); const link = yield* relayClient .linkEnvironment({ clerkToken: input.clerkToken, @@ -699,7 +449,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ); yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: target.environmentId, + expectedEnvironmentId: input.target.environmentId, expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); @@ -716,9 +466,6 @@ export function linkPrimaryEnvironmentToCloud(input: { endpointRuntime: link.endpointRuntime, }, }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not configure environment relay access.")), - ); - }); + .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts new file mode 100644 index 00000000000..ea924cae234 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -0,0 +1,33 @@ +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { + linkPrimaryEnvironmentToCloud, + type CloudLinkTarget, + unlinkPrimaryEnvironmentFromCloud, +} 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), +}); diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts new file mode 100644 index 00000000000..aa29a59677e --- /dev/null +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -0,0 +1,55 @@ +import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { + activateManagedRelayAuthentication, + deactivateManagedRelayAuthentication, + readManagedRelayClerkToken, +} from "./managedAuth"; + +vi.mock("@clerk/react", () => ({ + useAuth: vi.fn(), +})); + +vi.mock("../lib/runtime", () => ({ + runtime: { + runPromiseExit: vi.fn(), + }, +})); + +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { + removeRelayEnvironments: {}, + }, +})); + +afterEach(() => { + deactivateManagedRelayAuthentication(); +}); + +describe("managed relay authentication", () => { + it("clears all token access synchronously before account cleanup can fail", async () => { + activateManagedRelayAuthentication("account-1", async () => "account-1-token"); + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1"); + expect(await readManagedRelayClerkToken()).toBe("account-1-token"); + + deactivateManagedRelayAuthentication(); + const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined); + + expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); + expect(await readManagedRelayClerkToken()).toBeNull(); + await cleanup; + }); + + it("replaces an existing account session atomically", () => { + setManagedRelaySession(appAtomRegistry, { + accountId: "account-1", + readClerkToken: async () => "account-1-token", + }); + + activateManagedRelayAuthentication("account-2", async () => "account-2-token"); + + expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); + }); +}); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index b00c445f08d..2f631214501 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,8 +1,17 @@ import { useAuth } from "@clerk/react"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; -import { useEffect, type ReactNode } from "react"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { + reportAtomCommandResult, + settleAsyncResult, + settlePromise, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import { useEffect, useRef, type ReactNode } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useAtomCommand } from "../state/use-atom-command"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; let relayTokenProvider: (() => Promise) | null = null; @@ -11,25 +20,97 @@ export async function readManagedRelayClerkToken(): Promise { return relayTokenProvider?.() ?? null; } +export function deactivateManagedRelayAuthentication(): void { + relayTokenProvider = null; + setManagedRelaySession(appAtomRegistry, null); +} + +export function activateManagedRelayAuthentication( + accountId: string, + readClerkToken: () => Promise, +): void { + relayTokenProvider = readClerkToken; + setManagedRelaySession(appAtomRegistry, { + accountId, + readClerkToken, + }); +} + export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { - const { getToken, isSignedIn, userId } = useAuth(); + const { getToken, isLoaded, isSignedIn, userId } = useAuth({ + treatPendingAsSignedOut: false, + }); + const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + reportFailure: false, + reportDefect: false, + }); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef | null>(null); useEffect(() => { - relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; - setManagedRelaySession( - appAtomRegistry, - isSignedIn && userId - ? createManagedRelaySession({ - accountId: userId, - readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), - }) - : null, - ); + if (!isLoaded) { + return; + } + + let cancelled = false; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = () => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition.then(async () => { + const results = await Promise.all([ + removeRelayEnvironments(), + settleAsyncResult(() => + runtime.runPromiseExit( + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.resetTokenCache), + ), + ), + ), + ]); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }); + return accountTransitionRef.current; + }; + + if (!isSignedIn || !userId) { + deactivateManagedRelayAuthentication(); + if (previousAccount !== null) { + void queueAccountCleanup(); + } + } else { + const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); + const activateSession = () => { + if (!cancelled) { + activateManagedRelayAuthentication(userId, tokenProvider); + } + }; + const activateAfterTransition = (transition: Promise) => { + void (async () => { + const result = await settlePromise(async () => { + await transition; + activateSession(); + }); + reportAtomCommandResult(result, { label: "cloud account activation" }); + })(); + }; + if (previousAccount !== undefined && previousAccount !== null && previousAccount !== userId) { + deactivateManagedRelayAuthentication(); + activateAfterTransition(queueAccountCleanup()); + } else { + activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + } + } return () => { - relayTokenProvider = null; - setManagedRelaySession(appAtomRegistry, null); + cancelled = true; }; - }, [getToken, isSignedIn, userId]); + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); + + useEffect(() => () => deactivateManagedRelayAuthentication(), []); return children; } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index f34ad2f9c99..52f9b6496c9 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -17,8 +13,8 @@ import { type BrowserDpopKey, } from "./dpop"; -export const webRelayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, +export const relayDpopSignerLayer = Layer.effect( + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const keyLoadSemaphore = yield* Semaphore.make(1); @@ -39,24 +35,48 @@ export const webRelayDpopSignerLayer = Layer.effect( return generated; }), ); - const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); - return ManagedRelayDpopSigner.of({ + + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError(signerError), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "indexed-db", + cause: error, + }), + ), + Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - loadOrCreateBrowserDpopKey.pipe( - Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), + createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadOrCreateBrowserDpopKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), - Effect.mapError(signerError), - ), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); -export const webManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( - Layer.provideMerge(webRelayDpopSignerLayer), +export const managedRelayClientLayer = (relayUrl: string) => + ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe( + Layer.provideMerge(relayDpopSignerLayer), ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index a31ee9e16f3..5f29c121dbc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,10 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, - ManagedRelayClient, + ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, @@ -13,16 +13,16 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { webRuntime } from "../lib/runtime"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; const managedRelayAtomRuntime = Atom.runtime( Layer.effect( - ManagedRelayClient, - webRuntime.contextEffect.pipe( - Effect.map((context) => Context.get(context, ManagedRelayClient)), + ManagedRelay.ManagedRelayClient, + runtime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)), ), ), ); @@ -44,6 +44,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -51,7 +60,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -62,6 +71,15 @@ export function useManagedRelayDevices() { const accountId = session?.accountId ?? null; const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay device listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); @@ -69,7 +87,7 @@ export function useManagedRelayDevices() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 095ca842281..34fdacd214a 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -7,51 +7,68 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { HttpClient } from "effect/unstable/http"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { webRuntime } from "../lib/runtime"; +import { usePrimaryEnvironment } from "../state/environments"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readPrimaryCloudLinkState } from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( Layer.effect( HttpClient.HttpClient, - webRuntime.contextEffect.pipe( + runtime.contextEffect.pipe( Effect.map((context) => Context.get(context, HttpClient.HttpClient)), ), ), ); -const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => - primaryCloudLinkAtomRuntime - .atom(readPrimaryCloudLinkState()) +const primaryCloudLinkStateAtom = Atom.family((key: string) => { + const target = JSON.parse(key) as CloudLinkTarget; + return primaryCloudLinkAtomRuntime + .atom(readPrimaryCloudLinkState({ target })) .pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), - Atom.withLabel(`primary-cloud-link:${environmentId}`), - ), -); + Atom.withLabel(`primary-cloud-link:${target.environmentId}`), + ); +}); const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( AsyncResult.success(null), ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); -export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { - if (environmentId) { - appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); +function targetKey(target: CloudLinkTarget): string { + return JSON.stringify(target); +} + +export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { + if (target) { + appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } } export function usePrimaryCloudLinkState() { - const environmentId = usePrimaryEnvironmentId(); - const atom = environmentId - ? primaryCloudLinkStateAtom(environmentId) + const primary = usePrimaryEnvironment(); + const target = useMemo( + () => + primary?.entry.target._tag === "PrimaryConnectionTarget" + ? { + environmentId: primary.environmentId, + label: primary.label, + httpBaseUrl: primary.entry.target.httpBaseUrl, + wsBaseUrl: primary.entry.target.wsBaseUrl, + } + : null, + [primary], + ); + const atom = target + ? primaryCloudLinkStateAtom(targetKey(target)) : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); const refresh = useCallback(() => { - refreshPrimaryCloudLinkState(environmentId); - }, [environmentId]); + refreshPrimaryCloudLinkState(target); + }, [target]); let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); @@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() { error, isPending: result.waiting, refresh, + target, }; } diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index bb188d0b110..d42aa34baa2 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { hasCloudPublicConfig } from "./publicConfig.ts"; +import { + CloudPublicConfigMissingError, + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig.ts"; afterEach(() => { vi.unstubAllEnvs(); @@ -30,4 +34,12 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(false); }); + + it("reports the missing Clerk JWT template as structured configuration", () => { + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); + + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index f7b3ca6bc31..d9d0e5f44cb 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,5 +1,17 @@ import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; @@ -65,7 +77,7 @@ export function hasCloudPublicConfig(): boolean { export function resolveRelayClerkTokenOptions() { const { clerkJwtTemplate } = resolveCloudPublicConfig(); if (!clerkJwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(clerkJwtTemplate); } diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 8f2a25bc3a0..7bd8d4967e4 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -4,6 +4,7 @@ import { completeRelayClientInstallDialogClose, finishRelayClientInstall, readRelayClientInstallDialogState, + RelayClientInstallConfirmationConflictError, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, resetRelayClientInstallDialogForTests, @@ -67,4 +68,28 @@ describe("relay client install dialog coordinator", () => { completeRelayClientInstallDialogClose(); expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); }); + + it("rejects concurrent confirmation with the active install state", async () => { + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + respondToRelayClientInstallConfirmation(true); + await expect(confirmation).resolves.toBe(true); + reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + + const error = await requestRelayClientInstallConfirmation("2026.6.0").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(RelayClientInstallConfirmationConflictError); + expect(error).toMatchObject({ + requestedVersion: "2026.6.0", + activeVersion: "2026.5.2", + activeDialogStatus: "installing", + activeInstallStage: "downloading", + }); + expect(error).not.toHaveProperty("cause"); + expect((error as Error).message).toBe( + "Cannot confirm relay client installation 2026.6.0; installation 2026.5.2 has dialog status installing.", + ); + }); }); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts index 908890ad1f5..b1b0c6607e3 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -1,7 +1,23 @@ -import type { - RelayClientInstallProgressEvent, - RelayClientInstallProgressStage, +import { + RelayClientInstallProgressStageSchema, + type RelayClientInstallProgressEvent, + type RelayClientInstallProgressStage, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class RelayClientInstallConfirmationConflictError extends Schema.TaggedErrorClass()( + "RelayClientInstallConfirmationConflictError", + { + requestedVersion: Schema.String, + activeVersion: Schema.String, + activeDialogStatus: Schema.Literals(["confirming", "installing", "closing"]), + activeInstallStage: Schema.optional(RelayClientInstallProgressStageSchema), + }, +) { + override get message(): string { + return `Cannot confirm relay client installation ${this.requestedVersion}; installation ${this.activeVersion} has dialog status ${this.activeDialogStatus}.`; + } +} export type RelayClientInstallDialogState = | { readonly status: "idle" } @@ -47,7 +63,17 @@ export function subscribeRelayClientInstallDialog(listener: () => void): () => v export function requestRelayClientInstallConfirmation(version: string): Promise { if (state.status !== "idle") { - return Promise.reject(new Error("A relay client installation is already in progress.")); + const activeInstall = state.status === "closing" ? state.view : state; + return Promise.reject( + new RelayClientInstallConfirmationConflictError({ + requestedVersion: version, + activeVersion: activeInstall.version, + activeDialogStatus: state.status, + ...(activeInstall.status === "installing" + ? { activeInstallStage: activeInstall.stage } + : {}), + }), + ); } publish({ status: "confirming", version }); diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx new file mode 100644 index 00000000000..8dae5fed3b5 --- /dev/null +++ b/apps/web/src/commandPaletteContext.tsx @@ -0,0 +1,29 @@ +import { createContext, use, type ReactNode } from "react"; + +const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); + +export function OpenAddProjectCommandPaletteProvider(props: { + readonly children: ReactNode; + readonly openAddProject: () => void; +}) { + return ( + + {props.children} + + ); +} + +export function useOpenAddProjectCommandPalette(): () => void { + const openAddProject = use(OpenAddProjectCommandPaletteContext); + if (!openAddProject) { + throw new Error("Command palette actions must be used inside CommandPalette"); + } + return openAddProject; +} + +/** Read at event time so the chat tree does not subscribe to transient dialog state. */ +export function isCommandPaletteOpen(): boolean { + return ( + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + ); +} diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts deleted file mode 100644 index 04b25529f2f..00000000000 --- a/apps/web/src/commandPaletteStore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { create } from "zustand"; - -interface CommandPaletteOpenIntent { - kind: "add-project"; - requestId: number; -} - -interface CommandPaletteStore { - open: boolean; - openIntent: CommandPaletteOpenIntent | null; - setOpen: (open: boolean) => void; - toggleOpen: () => void; - openAddProject: () => void; - clearOpenIntent: () => void; -} - -export const useCommandPaletteStore = create((set) => ({ - open: false, - openIntent: null, - setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), - toggleOpen: () => - set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), - openAddProject: () => - set((state) => ({ - open: true, - openIntent: { - kind: "add-project", - requestId: (state.openIntent?.requestId ?? 0) + 1, - }, - })), - clearOpenIntent: () => set({ openIntent: null }), -})); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5c..cbfce7b43d0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,10 +3,6 @@ import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; -import { - clearShortcutModifierState, - syncShortcutModifierStateFromKeyboardEvent, -} from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; @@ -14,28 +10,6 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - syncShortcutModifierStateFromKeyboardEvent(event); - }; - const onWindowKeyUp = (event: KeyboardEvent) => { - syncShortcutModifierStateFromKeyboardEvent(event); - }; - const onWindowBlur = () => { - clearShortcutModifierState(); - }; - - window.addEventListener("keydown", onWindowKeyDown, true); - window.addEventListener("keyup", onWindowKeyUp, true); - window.addEventListener("blur", onWindowBlur); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown, true); - window.removeEventListener("keyup", onWindowKeyUp, true); - window.removeEventListener("blur", onWindowBlur); - }; - }, []); - useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27c5c311c60..03f24dac8e9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,4 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -11,9 +11,8 @@ import { import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useProject, useThread } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { type EnvMode, type EnvironmentOption, @@ -46,6 +45,8 @@ interface BranchToolbarProps { effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + startFromOrigin: boolean; + onStartFromOriginChange: (startFromOrigin: boolean) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -197,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + startFromOrigin, + onStartFromOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -207,8 +210,7 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); - const serverThread = useStore(serverThreadSelector); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -217,21 +219,17 @@ export const BranchToolbar = memo(function BranchToolbar({ : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const activeProjectSelector = useMemo( - () => createProjectSelectorByRef(activeProjectRef), - [activeProjectRef], - ); - const activeProject = useStore(activeProjectSelector); - const hasActiveThread = serverThread !== undefined || draftThread !== null; + const activeProject = useProject(activeProjectRef); + const hasActiveThread = serverThread !== null || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ activeWorktreePath, - hasServerThread: serverThread !== undefined, + hasServerThread: serverThread !== null, draftThreadEnvMode: draftThread?.envMode, }); - const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); + const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -285,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72391f714fc..7798f38e43e 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 7d5fddb6e29..00000000000 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ /dev/null @@ -1,892 +0,0 @@ -import "../index.css"; - -import { page } from "vite-plus/test/browser"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -const { - contextMenuShowMock, - openFileInPreviewMock, - openInPreferredEditorMock, - openUrlInPreviewMock, - readLocalApiMock, -} = vi.hoisted(() => ({ - contextMenuShowMock: vi.fn(), - openFileInPreviewMock: vi.fn(async () => undefined), - openInPreferredEditorMock: vi.fn(async () => "vscode"), - openUrlInPreviewMock: vi.fn(async () => undefined), - readLocalApiMock: vi.fn(() => ({ - contextMenu: { show: contextMenuShowMock }, - server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, - shell: { - openExternal: vi.fn(async () => undefined), - openInEditor: vi.fn(async () => undefined), - }, - })), -})); - -vi.mock("../editorPreferences", () => ({ - openInPreferredEditor: openInPreferredEditorMock, -})); - -vi.mock("../localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: readLocalApiMock, -})); - -vi.mock("../previewStateStore", async (importOriginal) => ({ - ...(await importOriginal()), - isPreviewSupportedInRuntime: () => true, -})); - -vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ - ...(await importOriginal()), - openFileInPreview: openFileInPreviewMock, - openUrlInPreview: openUrlInPreviewMock, -})); - -import ChatMarkdown from "./ChatMarkdown"; -import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; - -const threadRef = { - environmentId: EnvironmentId.make("environment-test"), - threadId: ThreadId.make("thread-test"), -}; - -describe("ChatMarkdown", () => { - afterEach(() => { - openInPreferredEditorMock.mockClear(); - openFileInPreviewMock.mockClear(); - openUrlInPreviewMock.mockClear(); - contextMenuShowMock.mockReset(); - readLocalApiMock.mockClear(); - useRightPanelStore.setState({ byThreadKey: {} }); - localStorage.clear(); - document.body.innerHTML = ""; - }); - - it("makes task-list checkboxes interactive when a change handler is provided", async () => { - const onTaskListChange = vi.fn(); - const screen = await render( - , - ); - - try { - const checkbox = page.getByRole("checkbox", { name: "Toggle task" }); - await expect.element(checkbox).not.toBeDisabled(); - await checkbox.click(); - expect(onTaskListChange).toHaveBeenCalledWith({ markerOffset: 2, checked: true }); - } finally { - await screen.unmount(); - } - }); - - it("rewrites file uri hrefs into direct paths before rendering", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", filePath); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps line anchors working after rewriting file uri hrefs", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}:1`); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`); - }); - } finally { - await screen.unmount(); - } - }); - - it("shows column information inline when present", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}:1:7`); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith( - expect.anything(), - `${filePath}:1:7`, - ); - }); - } finally { - await screen.unmount(); - } - }); - - it("disambiguates duplicate file basenames inline", async () => { - const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; - const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; - const screen = await render( - , - ); - - try { - await expect - .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) - .toBeInTheDocument(); - await expect - .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) - .toBeInTheDocument(); - } finally { - await screen.unmount(); - } - }); - - it("keeps normal web links unchanged", async () => { - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "OpenAI" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); - await expect.element(link).toHaveAttribute("target", "_blank"); - const favicon = link.element().querySelector(".chat-markdown-link-favicon"); - const leading = link.element().querySelector(".chat-markdown-link-leading"); - expect(favicon).not.toBeNull(); - expect(leading).not.toBeNull(); - expect(leading?.contains(favicon)).toBe(true); - expect(getComputedStyle(leading!).display).toBe("inline"); - expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); - expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); - expect(leading?.textContent).toBe("O"); - expect(link.element().textContent).toBe("OpenAI"); - expect(getComputedStyle(link.element()).textDecorationLine).toBe("none"); - expect(link.element().querySelector("img, svg")?.getBoundingClientRect().width).toBe(14); - await link.hover(); - expect(getComputedStyle(link.element()).backgroundImage).not.toBe("none"); - await expect.element(page.getByText("https://openai.com/docs")).toBeVisible(); - } finally { - await screen.unmount(); - } - }); - - it("opens web links in the integrated browser from the context menu", async () => { - contextMenuShowMock.mockResolvedValue("open-in-browser"); - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "OpenAI" }).element(); - link.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: 12, - clientY: 24, - }), - ); - - await vi.waitFor(() => { - expect(contextMenuShowMock).toHaveBeenCalled(); - expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs"); - }); - } finally { - await screen.unmount(); - } - }); - - it("offers integrated browser opening for HTML file links", async () => { - contextMenuShowMock.mockResolvedValue("open-in-browser"); - const filePath = "/repo/project/report.html"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "report.html" }).element(); - link.dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), - ); - - await vi.waitFor(() => { - expect(contextMenuShowMock).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: "open-in-browser", - label: "Open in integrated browser", - }), - ]), - { x: 4, y: 8 }, - ); - expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("opens code file links in the right-panel file preview", async () => { - const screen = await render( - , - ); - - try { - await page.getByRole("link", { name: "ChatMarkdown.tsx · L978" }).click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), - ).toMatchObject({ - isOpen: true, - activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", - surfaces: [ - expect.objectContaining({ - relativePath: "apps/web/src/components/ChatMarkdown.tsx", - revealLine: 978, - revealRequestId: 1, - }), - ], - }); - expect(openInPreferredEditorMock).not.toHaveBeenCalled(); - expect(openFileInPreviewMock).not.toHaveBeenCalled(); - }); - } finally { - await screen.unmount(); - } - }); - - it("opens HTML and PDF file links in the integrated browser preview", async () => { - const screen = await render( - , - ); - - try { - await page.getByRole("link", { name: "report.html" }).click(); - await page.getByRole("link", { name: "report.pdf" }).click(); - - await vi.waitFor(() => { - expect(openFileInPreviewMock).toHaveBeenNthCalledWith( - 1, - threadRef, - "/repo/project/report.html", - ); - expect(openFileInPreviewMock).toHaveBeenNthCalledWith( - 2, - threadRef, - "/repo/project/report.pdf", - ); - expect(openInPreferredEditorMock).not.toHaveBeenCalled(); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps opening file links in the editor from the context menu", async () => { - contextMenuShowMock.mockResolvedValue("open"); - const filePath = "/repo/project/src/index.ts"; - const screen = await render( - , - ); - - try { - page - .getByRole("link", { name: "index.ts" }) - .element() - .dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: 4, - clientY: 8, - }), - ); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps a favicon with the leading segment of a wrapping URL", async () => { - const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; - const screen = await render( -
- -
, - ); - - try { - const link = page.getByRole("link", { name: url }); - const leading = link.element().querySelector(".chat-markdown-link-leading"); - const favicon = link.element().querySelector(".chat-markdown-link-favicon"); - expect(leading).not.toBeNull(); - expect(favicon).not.toBeNull(); - expect(leading?.contains(favicon)).toBe(true); - expect(leading?.textContent).toBe("https://"); - expect(getComputedStyle(leading!).display).toBe("inline"); - expect(getComputedStyle(leading!).whiteSpace).toBe("nowrap"); - expect(getComputedStyle(favicon!).verticalAlign).not.toBe("baseline"); - expect(link.element().textContent).toBe(url); - expect(link.element().querySelectorAll("wbr").length).toBeGreaterThan(0); - const markdownRoot = link.element().closest(".chat-markdown"); - expect(markdownRoot).not.toBeNull(); - expect(markdownRoot!.scrollWidth).toBeLessThanOrEqual(markdownRoot!.clientWidth); - } finally { - await screen.unmount(); - } - }); - - it("renders file links with the shared file tag chip treatment", async () => { - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "package.json" }); - await expect.element(link).toHaveClass(/chat-markdown-file-link/); - const element = document.querySelector(".chat-markdown-file-link"); - expect(element?.querySelector("img, svg")).not.toBeNull(); - expect(getComputedStyle(element!).display).toBe("inline-flex"); - expect(getComputedStyle(element!).textDecorationLine).toBe("none"); - expect(getComputedStyle(element!).borderStyle).toBe("solid"); - expect(getComputedStyle(element!).userSelect).not.toBe("none"); - } finally { - await screen.unmount(); - } - }); - - it("renders sanitized details with the design-system collapsible", async () => { - const source = [ - "
", - "Expandable details section", - "", - "This content includes **formatted text**.", - "", - 'Safe inline HTML', - "", - "
", - ].join("\n"); - const screen = await render(); - - try { - const details = document.querySelector("[data-markdown-details]"); - const trigger = page.getByRole("button", { name: "Expandable details section" }); - expect(details).not.toBeNull(); - expect(details?.tagName).toBe("DIV"); - await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); - expect(details?.querySelector("strong")?.textContent).toBe("formatted text"); - expect(details?.querySelector("script")).toBeNull(); - expect(details?.querySelector("[title]")).toBeNull(); - - await trigger.click(); - await expect.element(trigger).toHaveAttribute("aria-expanded", "false"); - await trigger.click(); - await expect.element(trigger).toHaveAttribute("aria-expanded", "true"); - } finally { - await screen.unmount(); - } - }); - - it("renders footnotes as same-document references", async () => { - const source = [ - "A claim with supporting context.[^context]", - "", - "[^context]: Supporting **footnote text**.", - ].join("\n"); - const screen = await render(); - - try { - const reference = document.querySelector( - '.chat-markdown a[data-footnote-ref=""]', - ); - const footnotes = document.querySelector( - ".chat-markdown section[data-footnotes]", - ); - expect(reference).not.toBeNull(); - expect(reference?.getAttribute("href")).toMatch(/^#user-content-fn-/); - expect(reference?.hasAttribute("target")).toBe(false); - expect(footnotes).not.toBeNull(); - expect(footnotes?.querySelector("strong")?.textContent).toBe("footnote text"); - expect(footnotes?.querySelector("a[data-footnote-backref]")?.target).toBe( - "", - ); - } finally { - await screen.unmount(); - } - }); - - it("navigates hash links within the clicked markdown message", async () => { - const source = [ - "A claim with supporting context.[^context]", - "", - "[^context]: Supporting footnote text.", - ].join("\n"); - const originalUrl = window.location.href; - const scrollIntoView = vi - .spyOn(HTMLElement.prototype, "scrollIntoView") - .mockImplementation(() => undefined); - const screen = await render( -
- - -
, - ); - - try { - const markdownRoots = document.querySelectorAll(".chat-markdown"); - const secondRoot = markdownRoots[1]; - const secondReference = - secondRoot?.querySelector('a[data-footnote-ref=""]'); - const secondFootnote = secondRoot?.querySelector( - "section[data-footnotes] li[id]", - ); - expect(secondReference).not.toBeNull(); - expect(secondFootnote).not.toBeNull(); - - secondReference?.click(); - - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView.mock.instances[0]).toBe(secondFootnote); - expect(window.location.hash).toBe(secondReference?.hash); - - const secondBackref = secondRoot?.querySelector( - "a[data-footnote-backref]", - ); - expect(secondBackref).not.toBeNull(); - secondBackref?.click(); - - const secondReferenceTarget = secondReference?.closest("[id]"); - expect(scrollIntoView).toHaveBeenCalledTimes(2); - expect(scrollIntoView.mock.instances[1]).toBe(secondReferenceTarget); - } finally { - scrollIntoView.mockRestore(); - window.history.replaceState(window.history.state, "", originalUrl); - await screen.unmount(); - } - }); - - describe("code block chrome", () => { - it("shows icon-only language titles, text fallbacks, and filename overrides", async () => { - const source = [ - "```ts", - "const a = 1;", - "```", - "", - '```ts title="src/main.ts"', - "const b = 2;", - "```", - "", - "```text", - "plain", - "```", - ].join("\n"); - const screen = await render(); - - try { - const titles = [...document.querySelectorAll(".chat-markdown-codeblock-title")]; - expect(titles).toHaveLength(3); - - // Language with a known icon: icon XOR text — never the redundant pair. - const languageOnly = titles[0]!; - const hasIcon = languageOnly.querySelector("svg[data-pierre-icon]") != null; - const hasText = (languageOnly.textContent ?? "").includes("ts"); - expect(hasIcon || hasText).toBe(true); - expect(hasIcon && hasText).toBe(false); - if (hasIcon) { - const languageTrigger = page.getByLabelText("Language: ts").first(); - await languageTrigger.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("ts"); - }); - } - - // Explicit filename: text always shown. - expect(titles[1]!.textContent).toBe("src/main.ts"); - - // Unknown language: no icon attempt, text label. - expect(titles[2]!.querySelector("svg[data-pierre-icon]")).toBeNull(); - expect(titles[2]!.textContent).toBe("text"); - } finally { - await screen.unmount(); - } - }); - - it("toggles line wrapping per block", async () => { - const screen = await render( - , - ); - - try { - const block = document.querySelector(".chat-markdown-codeblock"); - expect(block?.getAttribute("data-wrap")).toBe("false"); - - const toggle = page.getByRole("button", { name: "Wrap lines" }); - await expect.element(toggle).not.toHaveAttribute("title"); - await toggle.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Wrap lines"); - }); - await toggle.click(); - expect(block?.getAttribute("data-wrap")).toBe("true"); - - await page.getByRole("button", { name: "Disable line wrap" }).click(); - expect(block?.getAttribute("data-wrap")).toBe("false"); - } finally { - await screen.unmount(); - } - }); - }); - - it("scrolls wide tables horizontally instead of letter-wrapping cells", async () => { - const header = `| ${Array.from({ length: 8 }, (_, i) => `ColumnHeading${i}`).join(" | ")} |`; - const separator = `| ${Array.from({ length: 8 }, () => "---").join(" | ")} |`; - const row = `| ${Array.from({ length: 8 }, () => "averylongunbrokencellvalue@example-domain.com").join(" | ")} |`; - const screen = await render( - , - ); - - try { - const viewport = document.querySelector( - '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', - ); - expect(viewport).not.toBeNull(); - expect(viewport!.querySelector("table")).not.toBeNull(); - // Content exceeds the container — the scroll-fade viewport scrolls - // horizontally rather than squishing columns. - expect(viewport!.scrollWidth).toBeGreaterThan(viewport!.clientWidth); - // And cells keep their longest word intact instead of breaking mid-word. - const cell = viewport!.querySelector("td"); - expect(cell!.getBoundingClientRect().width).toBeGreaterThan(100); - } finally { - await screen.unmount(); - } - }); - - describe("table chrome", () => { - const longCell = - "This service has been experiencing intermittent latency spikes during peak traffic hours and the on-call team is investigating."; - - it("truncates cells by default and expands them from the footer toggle", async () => { - const source = ["| Name | Notes |", "| --- | --- |", `| api | ${longCell} |`].join("\n"); - const screen = await render(); - - try { - const container = document.querySelector(".chat-markdown-table-container"); - expect(container?.getAttribute("data-expanded")).toBe("false"); - - const noteCell = [...document.querySelectorAll(".chat-markdown td")].at(-1)!; - expect(getComputedStyle(noteCell).whiteSpace).toBe("nowrap"); - expect(noteCell.scrollWidth).toBeGreaterThan(noteCell.clientWidth); - - const expandButton = page.getByRole("button", { name: "Expand table cells" }); - await expect.element(expandButton).not.toHaveAttribute("title"); - await expandButton.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Expand table cells"); - }); - await expandButton.click(); - expect(container?.getAttribute("data-expanded")).toBe("true"); - expect(getComputedStyle(noteCell).whiteSpace).not.toBe("nowrap"); - - await page.getByRole("button", { name: "Collapse table cells" }).click(); - expect(container?.getAttribute("data-expanded")).toBe("false"); - - const copyButton = page.getByRole("button", { name: "Copy table" }); - await expect.element(copyButton).not.toHaveAttribute("title"); - await copyButton.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("Copy table"); - }); - expect(document.querySelector(".chat-markdown [title]")).toBeNull(); - } finally { - await screen.unmount(); - } - }); - - it("retains column widths when cells expand", async () => { - const source = [ - "| ID | Owner | Status | Priority | Region | Summary | Long Description | Metrics | Payload | Notes |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", - '| 1001 | Ada Lovelace | Active | High | us-west-2 | Payment workflow migration | This cell has enough text to wrap across several lines when expanded without shrinking its column. | Requests: 128,440; Error rate: 0.04%; P95: 212ms | `{ "feature": "billing", "version": 3 }` | Needs post-release monitoring for 24 hours. |', - ].join("\n"); - const screen = await render(); - - try { - const viewport = document.querySelector( - '.chat-markdown-table-container [data-slot="scroll-area-viewport"]', - )!; - const table = viewport.querySelector("table")!; - const collapsedWidths = [...table.querySelectorAll("thead th")].map( - (cell) => cell.getBoundingClientRect().width, - ); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - - await page.getByRole("button", { name: "Expand table cells" }).click(); - - const expandedWidths = [...table.querySelectorAll("thead th")].map( - (cell) => cell.getBoundingClientRect().width, - ); - expect(expandedWidths).toHaveLength(collapsedWidths.length); - expandedWidths.forEach((width, index) => { - expect(width).toBeGreaterThanOrEqual(collapsedWidths[index]! - 1); - }); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - } finally { - await screen.unmount(); - } - }); - - it("exports tables as markdown and csv", async () => { - const source = [ - "| Name | Count |", - "| --- | ---: |", - '| widget, "deluxe" | 2 |', - "| plain | 1 |", - ].join("\n"); - const screen = await render(); - - try { - const table = document.querySelector(".chat-markdown table")!; - expect(serializeTableElementToMarkdown(table)).toBe( - ["| Name | Count |", "| --- | ---: |", '| widget, "deluxe" | 2 |', "| plain | 1 |"].join( - "\n", - ), - ); - expect(serializeTableElementToCsv(table)).toBe( - ["Name,Count", '"widget, ""deluxe""",2', "plain,1"].join("\n"), - ); - } finally { - await screen.unmount(); - } - }); - }); - - describe("copying rendered markdown", () => { - function copySelectedMarkdown(): { text: string; html: string } { - const root = document.querySelector(".chat-markdown"); - if (!root) throw new Error("chat-markdown root not rendered"); - const selection = window.getSelection(); - if (!selection) throw new Error("selection unavailable"); - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(root); - selection.addRange(range); - - const clipboardData = new DataTransfer(); - root.dispatchEvent( - new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true }), - ); - selection.removeAllRanges(); - return { - text: clipboardData.getData("text/plain"), - html: clipboardData.getData("text/html"), - }; - } - - it("round-trips links, emphasis, and inline code", async () => { - const screen = await render( - , - ); - - try { - const { text, html } = copySelectedMarkdown(); - expect(text).toBe( - "Check out [Anthropic](https://anthropic.com), **bold**, *italic*, and `code`.", - ); - expect(html).toContain('href="https://anthropic.com"'); - } finally { - await screen.unmount(); - } - }); - - it("round-trips block structure: headings, lists, quotes, and fences", async () => { - const source = [ - "## Heading", - "", - "- first", - "- second", - " - nested", - "", - "1. one", - "2. two", - "", - "- [x] done", - "- [ ] todo", - "", - "> quoted", - "", - "```ts", - "const x = 1;", - "", - "const y = 2;", - "```", - ].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("round-trips tables with alignment", async () => { - const source = ["| Name | Count |", "| --- | ---: |", "| a | 1 |", "| b | 2 |"].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("round-trips details rendered through the collapsible", async () => { - const source = [ - "
", - "Expandable details section", - "", - "This content includes **formatted text**.", - "
", - ].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("excludes the code block header chrome from copied markdown", async () => { - const source = ["```ts", "const x = 1;", "```"].join("\n"); - const screen = await render(); - - try { - const { text } = copySelectedMarkdown(); - expect(text).toBe(source); - } finally { - await screen.unmount(); - } - }); - - it("copies file links as markdown and skips UI affordances", async () => { - const filePath = "/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const { text, html } = copySelectedMarkdown(); - expect(text).toBe( - `See [PermissionRule.ts](/Users/yashsingh/p/t3code/src/utils/permissions/PermissionRule.ts) for details.`, - ); - expect(html).toContain("PermissionRule.ts"); - expect(html).not.toContain(" { - const source = - "Use $agent-browser with [package.json](path/to/package.json) before continuing."; - const screen = await render( - , - ); - - try { - const root = document.querySelector(".chat-markdown")!; - const selection = window.getSelection()!; - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(root); - selection.addRange(range); - expect(selection.toString()).toContain("Agent Browser"); - expect(selection.toString()).toContain("package.json"); - selection.removeAllRanges(); - - const { text, html } = copySelectedMarkdown(); - expect(text).toBe(source); - expect(html).toContain("Agent Browser"); - expect(html).toContain("package.json"); - expect(html).not.toContain(" Promise>; + onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -942,54 +960,6 @@ function MarkdownExternalLinkContent({ ); } -function MarkdownExternalLink({ - href, - threadRef, - children, - ...props -}: React.ComponentProps<"a"> & { - href: string; - threadRef?: ScopedThreadRef | undefined; -}) { - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - if (!threadRef || !isPreviewSupportedInRuntime()) return; - event.preventDefault(); - event.stopPropagation(); - - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - void openUrlInPreview(threadRef, href).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open link in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - } else if (clicked === "open-external") { - void api.shell.openExternal(href); - } - }, - [href, threadRef], - ); - - return ( - - {children} - - ); -} - const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -1001,19 +971,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ copyMarkdown, theme, threadRef, + onOpen, + onOpenInBrowser, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Open in editor is unavailable", - }); - return; - } - - void openInPreferredEditor(api, targetPath).catch((error) => { + void (async () => { + const result = await onOpen(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1021,8 +989,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ description: error instanceof Error ? error.message : "An error occurred.", }), ); - }); - }, [targetPath]); + })(); + }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { if (!threadRef || !workspaceRelativePath) { @@ -1033,8 +1001,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { - if (!threadRef) return; - void openFileInPreview(threadRef, iconPath).catch((error) => { + if (!onOpenInBrowser) { + return; + } + void (async () => { + const result = await onOpenInBrowser(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1042,8 +1017,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ description: error instanceof Error ? error.message : "An error occurred.", }), ); - }); - }, [iconPath, threadRef]); + })(); + }, [onOpenInBrowser]); const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1085,12 +1060,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; - const canOpenInBrowser = - Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, - ...(canOpenInBrowser + ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, @@ -1115,15 +1088,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [ - displayPath, - handleCopy, - handleOpenInBrowser, - handleOpenInEditor, - iconPath, - targetPath, - threadRef, - ], + [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], ); return ( @@ -1137,7 +1102,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - if (threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath)) { + if (onOpenInBrowser) { handleOpenInBrowser(); return; } @@ -1175,8 +1140,9 @@ function areMarkdownFileLinkPropsEqual( previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && - previous.threadRef?.environmentId === next.threadRef?.environmentId && - previous.threadRef?.threadId === next.threadRef?.threadId && + previous.threadRef === next.threadRef && + previous.onOpen === next.onOpen && + previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } @@ -1192,6 +1158,19 @@ function ChatMarkdown({ lineBreaks = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); + const environmentId = useActiveEnvironmentId(); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig?.availableEditors ?? [], + ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -1226,6 +1205,46 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openExternalLinkInPreview = useCallback( + (url: string) => { + if (!threadRef) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "Thread context is unavailable.", + }), + ), + ), + ); + } + return openUrlInPreview({ threadRef, url, openPreview }); + }, + [openPreview, threadRef], + ); + const openMarkdownFileInPreview = useCallback( + (path: string) => { + if (!threadRef || preparedConnection._tag === "None") { + return Promise.resolve( + AsyncResult.failure( + Cause.fail( + new BrowserPreviewUnavailableError({ + message: "Environment is not connected.", + }), + ), + ), + ); + } + return openFileInPreview({ + threadRef, + filePath: path, + httpBaseUrl: preparedConnection.value.httpBaseUrl, + createAssetUrl, + openPreview, + }); + }, + [createAssetUrl, openPreview, preparedConnection, threadRef], + ); const markdownComponents = useMemo( () => ({ p({ node: _node, children, ...props }) { @@ -1277,11 +1296,11 @@ function ChatMarkdown({ const faviconHost = resolveExternalLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const link = ( - { @@ -1290,6 +1309,29 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); } }} + onContextMenu={(event) => { + if (!canOpenInPreview || !href) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + void 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 }, + ) + .then((clicked) => { + if (clicked === "open-in-browser") { + void openExternalLinkInPreview(href); + return; + } + if (clicked === "open-external") return api.shell.openExternal(href); + }) + .catch(() => undefined); + }} > {faviconHost ? ( @@ -1298,7 +1340,7 @@ function ChatMarkdown({ ) : ( children )} - + ); if (!faviconHost || !href) { return link; @@ -1339,6 +1381,14 @@ function ChatMarkdown({ copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} threadRef={threadRef} + onOpen={openInPreferredEditor} + onOpenInBrowser={ + threadRef && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(fileLinkMeta.filePath) + ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } className={props.className} /> ); @@ -1384,10 +1434,13 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, - threadRef, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, resolvedTheme, skills, text, + threadRef, ], ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx deleted file mode 100644 index 0bb881a8fac..00000000000 --- a/apps/web/src/components/ChatView.browser.tsx +++ /dev/null @@ -1,7456 +0,0 @@ -// Production CSS is part of the behavior under test because row height depends on it. -import "../index.css"; - -import { - EventId, - ORCHESTRATION_WS_METHODS, - EnvironmentId, - type EnvironmentApi, - type MessageId, - type OrchestrationReadModel, - type ProjectId, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type TerminalMetadataStreamEvent, - type ServerLifecycleWelcomePayload, - type ThreadId, - type TurnId, - WS_METHODS, - OrchestrationSessionStatus, - DEFAULT_SERVER_SETTINGS, - DEFAULT_TERMINAL_ID, - ServerConfig as ServerConfigSchema, -} from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { HttpResponse, http, ws } from "msw"; -import { setupWorker } from "msw/browser"; -import { page } from "vite-plus/test/browser"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { useCommandPaletteStore } from "../commandPaletteStore"; -import { useComposerDraftStore, DraftId } from "../composerDraftStore"; -import { - __resetEnvironmentApiOverridesForTests, - __setEnvironmentApiOverrideForTests, -} from "../environmentApi"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime/catalog"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - removeInlineTerminalContextPlaceholder, - type TerminalContextDraft, -} from "../lib/terminalContext"; -import { isMacPlatform } from "../lib/utils"; -import { __resetLocalApiForTests } from "../localApi"; -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { getServerConfig } from "../rpc/serverState"; -import { getRouter } from "../router"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; -import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; -import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; -import { terminalSessionManager } from "../terminalSessionState"; -import { useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useUiStateStore } from "../uiStateStore"; -import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; -import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; - -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; - -vi.mock("../lib/vcsStatusState", () => { - const status = { - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: true, - refName: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }, - error: null, - cause: null, - isPending: false, - }; - - return { - getVcsStatusDataForTarget: (state: typeof status) => state.data, - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - -const THREAD_ID = "thread-browser-test" as ThreadId; -const THREAD_TITLE = "Browser test thread"; -const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; -const PROJECT_ID = "project-1" as ProjectId; -const SECOND_PROJECT_ID = "project-2" as ProjectId; -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); -const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); -const THREAD_KEY = scopedThreadKey(THREAD_REF); -const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( - { - environmentId: LOCAL_ENVIRONMENT_ID, - id: PROJECT_ID, - cwd: "/repo/project", - repositoryIdentity: null, - }, - { - sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, - }, -); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; -const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; -const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: ServerLifecycleWelcomePayload; - terminalMetadataEvents: ReadonlyArray; -} - -let fixture: TestFixture; -const rpcHarness = new BrowserWsRpcHarness(); -const wsRequests = rpcHarness.requests; -let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; -const wsLink = ws.link(/ws(s)?:\/\/.*/); -const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); - -interface ViewportSpec { - name: string; - width: number; - height: number; - textTolerancePx: number; - attachmentTolerancePx: number; -} - -const DEFAULT_VIEWPORT: ViewportSpec = { - name: "desktop", - width: 960, - height: 1_100, - textTolerancePx: 44, - attachmentTolerancePx: 56, -}; -const WIDE_FOOTER_VIEWPORT: ViewportSpec = { - name: "wide-footer", - width: 1_400, - height: 1_100, - textTolerancePx: 44, - attachmentTolerancePx: 56, -}; -const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { - name: "compact-footer", - width: 430, - height: 932, - textTolerancePx: 56, - attachmentTolerancePx: 56, -}; - -interface MountedChatView { - [Symbol.asyncDispose]: () => Promise; - cleanup: () => Promise; - setViewport: (viewport: ViewportSpec) => Promise; - setContainerSize: (viewport: Pick) => Promise; - router: ReturnType; -} - -function isoAt(offsetSeconds: number): string { - return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); -} - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [], - slashCommands: [], - skills: [], - }, - ], - availableEditors: [], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...DEFAULT_CLIENT_SETTINGS, - }, - }; -} - -function createMockEnvironmentApi(input: { - browse: EnvironmentApi["filesystem"]["browse"]; - dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; -}): EnvironmentApi { - return { - terminal: {} as EnvironmentApi["terminal"], - projects: {} as EnvironmentApi["projects"], - filesystem: { - browse: input.browse, - }, - assets: { - createUrl: vi.fn(async ({ resource }) => ({ - relativeUrl: `/api/assets/test/${encodeURIComponent( - resource._tag === "attachment" - ? resource.attachmentId - : resource._tag === "project-favicon" - ? "favicon.svg" - : (resource.path.split(/[\\/]/).at(-1) ?? "asset"), - )}`, - expiresAt: Date.now() + 60_000, - })), - }, - sourceControl: {} as EnvironmentApi["sourceControl"], - vcs: {} as EnvironmentApi["vcs"], - git: {} as EnvironmentApi["git"], - review: {} as EnvironmentApi["review"], - orchestration: { - dispatchCommand: input.dispatchCommand, - getTurnDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getTurnDiff"], - getFullThreadDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], - getArchivedShellSnapshot: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], - subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], - subscribeThread: (() => () => - undefined) as EnvironmentApi["orchestration"]["subscribeThread"], - }, - preview: { - open: () => { - throw new Error("Not implemented in browser test."); - }, - navigate: () => { - throw new Error("Not implemented in browser test."); - }, - refresh: () => { - throw new Error("Not implemented in browser test."); - }, - close: () => { - throw new Error("Not implemented in browser test."); - }, - list: () => Promise.resolve({ sessions: [] }), - reportStatus: () => { - throw new Error("Not implemented in browser test."); - }, - automation: { - connect: () => () => undefined, - respond: () => Promise.resolve(), - reportOwner: () => Promise.resolve(), - clearOwner: () => Promise.resolve(), - }, - onEvent: () => () => undefined, - subscribePorts: () => () => undefined, - } as EnvironmentApi["preview"], - }; -} - -function createUserMessage(options: { - id: MessageId; - text: string; - offsetSeconds: number; - attachments?: Array<{ - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - }>; -}) { - return { - id: options.id, - role: "user" as const, - text: options.text, - ...(options.attachments ? { attachments: options.attachments } : {}), - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { - return { - id: options.id, - role: "assistant" as const, - text: options.text, - turnId: null, - streaming: false, - createdAt: isoAt(options.offsetSeconds), - updatedAt: isoAt(options.offsetSeconds + 1), - }; -} - -function createTerminalContext(input: { - id: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; - text: string; -}): TerminalContextDraft { - return { - id: input.id, - threadId: THREAD_ID, - terminalId: `terminal-${input.id}`, - terminalLabel: input.terminalLabel, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - text: input.text, - createdAt: NOW_ISO, - }; -} - -function createSnapshotForTargetUser(options: { - targetMessageId: MessageId; - targetText: string; - targetAttachmentCount?: number; - sessionStatus?: OrchestrationSessionStatus; -}): OrchestrationReadModel { - const messages: Array = []; - - for (let index = 0; index < 22; index += 1) { - const isTarget = index === 3; - const userId = `msg-user-${index}` as MessageId; - const assistantId = `msg-assistant-${index}` as MessageId; - const attachments = - isTarget && (options.targetAttachmentCount ?? 0) > 0 - ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ - type: "image" as const, - id: `attachment-${attachmentIndex + 1}`, - name: `attachment-${attachmentIndex + 1}.png`, - mimeType: "image/png", - sizeBytes: 128, - })) - : undefined; - - messages.push( - createUserMessage({ - id: isTarget ? options.targetMessageId : userId, - text: isTarget ? options.targetText : `filler user message ${index}`, - offsetSeconds: messages.length * 3, - ...(attachments ? { attachments } : {}), - }), - ); - messages.push( - createAssistantMessage({ - id: assistantId, - text: `assistant filler ${index}`, - offsetSeconds: messages.length * 3, - }), - ); - } - - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: THREAD_TITLE, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages, - activities: [], - proposedPlans: [], - checkpoints: [], - 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("*/api/assets/test/:assetName", () => - HttpResponse.text(ATTACHMENT_SVG, { - headers: { - "Content-Type": "image/svg+xml", - }, - }), - ), -); - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: ViewportSpec): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { - timeout: 4_000, - interval: 16, - }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function waitForURL( - router: ReturnType, - predicate: (pathname: string) => boolean, - errorMessage: string, -): Promise { - let pathname = ""; - await vi.waitFor( - () => { - pathname = router.state.location.pathname; - expect(predicate(pathname), errorMessage).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - return pathname; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[contenteditable="true"]'), - "Unable to find composer editor.", - ); -} - -async function pressComposerKey(key: string): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const keydownEvent = new KeyboardEvent("keydown", { - key, - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(keydownEvent); - if (keydownEvent.defaultPrevented) { - await waitForLayout(); - return; - } - - const beforeInputEvent = new InputEvent("beforeinput", { - data: key, - inputType: "insertText", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(beforeInputEvent); - if (beforeInputEvent.defaultPrevented) { - await waitForLayout(); - return; - } - - if ( - typeof document.execCommand === "function" && - document.execCommand("insertText", false, key) - ) { - await waitForLayout(); - return; - } - - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - throw new Error("Unable to resolve composer selection for text input."); - } - const range = selection.getRangeAt(0); - range.deleteContents(); - const textNode = document.createTextNode(key); - range.insertNode(textNode); - range.setStartAfter(textNode); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - composerEditor.dispatchEvent( - new InputEvent("input", { - data: key, - inputType: "insertText", - bubbles: true, - }), - ); - await waitForLayout(); -} - -async function pressComposerUndo(): Promise { - const composerEditor = await waitForComposerEditor(); - const useMetaForMod = isMacPlatform(navigator.platform); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "z", - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); -} - -async function waitForComposerText(expectedText: string): Promise { - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe( - expectedText, - ); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function setComposerSelectionByTextOffsets(options: { - start: number; - end: number; - direction?: "forward" | "backward"; -}): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const resolvePoint = (targetOffset: number) => { - const traversedRef = { value: 0 }; - - const visitNode = (node: Node): { node: Node; offset: number } | null => { - if (node.nodeType === Node.TEXT_NODE) { - const textLength = node.textContent?.length ?? 0; - if (targetOffset <= traversedRef.value + textLength) { - return { - node, - offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)), - }; - } - traversedRef.value += textLength; - return null; - } - - if (node instanceof HTMLBRElement) { - const parent = node.parentNode; - if (!parent) { - return null; - } - const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node); - if (targetOffset <= traversedRef.value) { - return { node: parent, offset: siblingIndex }; - } - if (targetOffset <= traversedRef.value + 1) { - return { node: parent, offset: siblingIndex + 1 }; - } - traversedRef.value += 1; - return null; - } - - if (node instanceof Element || node instanceof DocumentFragment) { - for (const child of node.childNodes) { - const point = visitNode(child); - if (point) { - return point; - } - } - } - - return null; - }; - - return ( - visitNode(composerEditor) ?? { - node: composerEditor, - offset: composerEditor.childNodes.length, - } - ); - }; - - const startPoint = resolvePoint(options.start); - const endPoint = resolvePoint(options.end); - const selection = window.getSelection(); - if (!selection) { - throw new Error("Unable to resolve window selection."); - } - selection.removeAllRanges(); - - if (options.direction === "backward" && "setBaseAndExtent" in selection) { - selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset); - await waitForLayout(); - return; - } - - const range = document.createRange(); - range.setStart(startPoint.node, startPoint.offset); - range.setEnd(endPoint.node, endPoint.offset); - selection.addRange(range); - await waitForLayout(); -} - -async function selectAllComposerContent(): Promise { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - const selection = window.getSelection(); - if (!selection) { - throw new Error("Unable to resolve window selection."); - } - selection.removeAllRanges(); - const range = document.createRange(); - range.selectNodeContents(composerEditor); - selection.addRange(range); - await waitForLayout(); -} - -async function waitForComposerMenuItem(itemId: string): Promise { - return waitForElement( - () => document.querySelector(`[data-composer-item-id="${itemId}"]`), - `Unable to find composer menu item "${itemId}".`, - ); -} -async function waitForSendButton(): Promise { - return waitForElement( - () => document.querySelector('button[aria-label="Send message"]'), - "Unable to find send button.", - ); -} - -function findComposerProviderModelPicker(): HTMLButtonElement | null { - return document.querySelector('[data-chat-provider-model-picker="true"]'); -} - -function findButtonByText(text: string): HTMLButtonElement | null { - return (Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === text, - ) ?? null) as HTMLButtonElement | null; -} - -async function waitForButtonByText(text: string): Promise { - return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); -} - -function findButtonContainingText(text: string): HTMLElement | null { - return ( - Array.from(document.querySelectorAll('button, [role="button"]')).find((button) => - button.textContent?.includes(text), - ) ?? null - ); -} - -async function waitForButtonContainingText(text: string): Promise { - return waitForElement( - () => findButtonContainingText(text), - `Unable to find button containing "${text}".`, - ); -} - -async function waitForSelectItemContainingText(text: string): Promise { - return waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="select-item"]')).find((item) => - item.textContent?.includes(text), - ) ?? null, - `Unable to find select item containing "${text}".`, - ); -} - -async function expectComposerActionsContained(): Promise { - const footer = await waitForElement( - () => document.querySelector('[data-chat-composer-footer="true"]'), - "Unable to find composer footer.", - ); - const actions = await waitForElement( - () => document.querySelector('[data-chat-composer-actions="right"]'), - "Unable to find composer actions container.", - ); - - await vi.waitFor( - () => { - const footerRect = footer.getBoundingClientRect(); - const actionButtons = Array.from(actions.querySelectorAll("button")); - expect(actionButtons.length).toBeGreaterThanOrEqual(1); - - const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); - const firstTop = buttonRects[0]?.top ?? 0; - - for (const rect of buttonRects) { - expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); - expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); - expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); - } - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForInteractionModeButton( - expectedLabel: "Build" | "Plan", -): Promise { - return waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === expectedLabel, - ) as HTMLButtonElement | null, - `Unable to find ${expectedLabel} interaction mode button.`, - ); -} - -async function waitForServerConfigToApply(): Promise { - await vi.waitFor( - () => { - expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( - true, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - await waitForLayout(); -} - -function dispatchChatNewShortcut(): void { - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "o", - shiftKey: true, - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); -} - -function dispatchConfiguredDiffToggleShortcut(): void { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "g", - shiftKey: true, - altKey: true, - bubbles: true, - cancelable: true, - }), - ); -} - -function releaseModShortcut(key?: string): void { - window.dispatchEvent( - new KeyboardEvent("keyup", { - key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), - metaKey: false, - ctrlKey: false, - bubbles: true, - cancelable: true, - }), - ); -} - -async function triggerChatNewShortcutUntilPath( - router: ReturnType, - predicate: (pathname: string) => boolean, - errorMessage: string, -): Promise { - let pathname = router.state.location.pathname; - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - dispatchChatNewShortcut(); - await waitForLayout(); - pathname = router.state.location.pathname; - if (predicate(pathname)) { - return pathname; - } - } - throw new Error(`${errorMessage} Last path: ${pathname}`); -} - -async function openCommandPaletteFromTrigger(): Promise { - const trigger = page.getByTestId("command-palette-trigger"); - await expect.element(trigger).toBeInTheDocument(); - await trigger.click(); - await waitForElement( - () => document.querySelector('[data-testid="command-palette"]'), - "Command palette should have opened from the sidebar trigger.", - ); -} - -async function waitForNewThreadShortcutLabel(): Promise { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await newThreadButton.hover(); - const shortcutLabel = isMacPlatform(navigator.platform) - ? "New thread (⇧⌘O)" - : "New thread (Ctrl+Shift+O)"; - await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); -} - -async function waitForCommandPaletteShortcutLabel(): Promise { - await waitForElement( - () => document.querySelector('[data-testid="command-palette-trigger"] kbd'), - "Command palette shortcut label did not render.", - ); -} - -async function waitForCommandPaletteInput(placeholder: string): Promise { - return waitForElement( - () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, - `Command palette input with placeholder "${placeholder}" did not render.`, - ); -} - -function getCommandPaletteLegendEntries(): string[] { - const footer = document.querySelector('[data-slot="command-footer"]'); - if (!footer) { - return []; - } - - return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) - .map((group) => - Array.from(group.children) - .map((child) => child.textContent?.trim() ?? "") - .filter((value) => value.length > 0) - .join(" "), - ) - .filter((value) => value.length > 0); -} - -async function dispatchInputKey( - input: HTMLInputElement, - init: Pick, -): Promise { - input.focus(); - input.dispatchEvent( - new KeyboardEvent("keydown", { - bubbles: true, - cancelable: true, - ...init, - }), - ); - await waitForLayout(); -} - -async function mountChatView(options: { - viewport: ViewportSpec; - snapshot: OrchestrationReadModel; - configureFixture?: (fixture: TestFixture) => void; - resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; - initialPath?: string; -}): Promise { - fixture = buildFixture(options.snapshot); - options.configureFixture?.(fixture); - customWsRpcResolver = options.resolveRpc ?? null; - await setViewport(options.viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.top = "0"; - host.style.left = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ - initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], - }), - ); - - const screen = await render( - - - , - { - container: host, - }, - ); - - await waitForWsClient(); - await waitForAppBootstrap(); - await waitForLayout(); - - const cleanup = async () => { - customWsRpcResolver = null; - await screen.unmount(); - host.remove(); - await waitForLayout(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - setViewport: async (viewport: ViewportSpec) => { - await setViewport(viewport); - await waitForProductionStyles(); - }, - setContainerSize: async (viewport) => { - host.style.width = `${viewport.width}px`; - host.style.height = `${viewport.height}px`; - await waitForLayout(); - }, - router, - }; -} - -describe("ChatView timeline estimator parity (full app)", () => { - beforeAll(async () => { - fixture = buildFixture( - createSnapshotForTargetUser({ - targetMessageId: "msg-user-bootstrap" as MessageId, - targetText: "bootstrap", - }), - ); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { - url: "/mockServiceWorker.js", - }, - }); - }); - - afterAll(async () => { - await rpcHarness.disconnect(); - await worker.stop(); - }); - - beforeEach(async () => { - await rpcHarness.reset({ - resolveUnary: resolveWsRpc, - getInitialStreamValues: (request) => { - if (request._tag === WS_METHODS.subscribeServerLifecycle) { - return [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - }, - ]; - } - if (request._tag === WS_METHODS.subscribeServerConfig) { - return [ - { - version: 1, - type: "snapshot", - config: encodeServerConfig(fixture.serverConfig), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { - return [ - { - kind: "snapshot", - snapshot: toShellSnapshot(fixture.snapshot), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { - const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); - return thread - ? [ - { - kind: "snapshot", - snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread, - }, - }, - ] - : []; - } - if (request._tag === WS_METHODS.subscribeTerminalMetadata) { - return fixture.terminalMetadataEvents; - } - return []; - }, - }); - await __resetLocalApiForTests(); - await setViewport(DEFAULT_VIEWPORT); - localStorage.clear(); - document.body.innerHTML = ""; - wsRequests.length = 0; - customWsRpcResolver = null; - __resetEnvironmentApiOverridesForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - Reflect.deleteProperty(window, "desktopBridge"); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - stickyActiveProvider: null, - }); - useCommandPaletteStore.setState({ - open: false, - openIntent: null, - }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); - useUiStateStore.setState({ - projectExpandedById: {}, - projectOrder: [], - threadLastVisitedAtById: {}, - }); - useTerminalUiStateStore.persist.clearStorage(); - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: {}, - }); - useRightPanelStore.persist.clearStorage(); - useRightPanelStore.setState({ byThreadKey: {} }); - }); - - afterEach(() => { - customWsRpcResolver = null; - document.body.innerHTML = ""; - }); - - it("renders locked single-environment mobile run context as a static workspace label", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-locked-workspace" as MessageId, - targetText: "locked mobile workspace", - }), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Local checkout", - ) ?? null, - "Unable to find static mobile workspace label.", - ); - - expect(findButtonByText("Local checkout")).toBeNull(); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps dismiss-only composer banners aligned on mobile", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-mobile-version-banner" as MessageId, - targetText: "mobile version banner", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - environment: { - ...nextFixture.serverConfig.environment, - serverVersion: "9.9.9", - }, - }; - }, - }); - - try { - const banner = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="alert"]')).find( - (element) => element.textContent?.includes("Client and server versions differ"), - ) ?? null, - "Unable to find version mismatch banner.", - ); - const title = banner.querySelector('[data-slot="alert-title"]'); - const description = banner.querySelector('[data-slot="alert-description"]'); - const dismissButton = banner.querySelector( - 'button[aria-label="Dismiss version mismatch warning"]', - ); - - expect(title).toBeTruthy(); - expect(description).toBeTruthy(); - expect(dismissButton).toBeTruthy(); - expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( - description!.getBoundingClientRect().top, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("re-expands the bootstrap project using its logical key", async () => { - useUiStateStore.setState({ - projectExpandedById: { - [PROJECT_LOGICAL_KEY]: false, - }, - projectOrder: [PROJECT_LOGICAL_KEY], - threadLastVisitedAtById: {}, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, - targetText: "bootstrap project expand", - }), - }); - - try { - await vi.waitFor( - () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows an explicit empty state for projects without threads in the sidebar", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - }); - - try { - await expect.element(page.getByText("No threads yet")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd for draft threads without a worktree path", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-launch-context-target" as MessageId, - targetText: "launch context worktree override", - }); - const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); - if (targetThread) { - Object.assign(targetThread, { - branch: "feature/branch", - worktreePath: "/repo/worktrees/feature-branch", - }); - } - - useTerminalUiStateStore.setState({ - terminalUiStateByThreadKey: { - [THREAD_KEY]: { - terminalOpen: true, - terminalHeight: 280, - terminalIds: ["default"], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot, - configureFixture: (nextFixture) => { - nextFixture.terminalMetadataEvents = [ - { - type: "upsert", - terminal: { - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: false, - label: "Terminal 1", - updatedAt: isoAt(0), - }, - }, - ]; - }, - }); - - try { - await vi.waitFor( - () => { - const attachRequest = wsRequests - .toReversed() - .find((request) => request._tag === WS_METHODS.terminalAttach) as - | { - _tag: string; - cwd?: string; - worktreePath?: string | null; - env?: Record; - } - | undefined; - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - cwd: "/repo/project", - worktreePath: null, - 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 terminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - terminalToggle.click(); - - await vi.waitFor( - () => { - const attachRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalAttach, - ); - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - }); - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .isOpen, - ).toBe(false); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the compact chat header on one row", async () => { - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-compact-header" as MessageId, - targetText: "keep the compact header aligned", - }), - }); - - try { - const chatHeader = await waitForElement( - () => document.querySelector("[data-chat-header]"), - "Unable to find chat header.", - ); - const threadTitle = await waitForElement( - () => chatHeader.querySelector("h2"), - "Unable to find thread title.", - ); - const headerActions = await waitForElement( - () => document.querySelector("[data-chat-header-actions]"), - "Unable to find chat header actions.", - ); - - const headerRect = chatHeader.getBoundingClientRect(); - const titleRect = threadTitle.getBoundingClientRect(); - const actionsRect = headerActions.getBoundingClientRect(); - const headerCenter = headerRect.top + headerRect.height / 2; - - expect(headerRect.height).toBe(52); - expect(titleRect.top).toBeGreaterThanOrEqual(headerRect.top); - expect(titleRect.bottom).toBeLessThanOrEqual(headerRect.bottom); - expect(actionsRect.top).toBeGreaterThanOrEqual(headerRect.top); - expect(actionsRect.bottom).toBeLessThanOrEqual(headerRect.bottom); - expect(Math.abs(titleRect.top + titleRect.height / 2 - headerCenter)).toBeLessThanOrEqual(1); - expect(Math.abs(actionsRect.top + actionsRect.height / 2 - headerCenter)).toBeLessThanOrEqual( - 1, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps panel toggles fixed and can maximize the right panel", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-maximize-right-panel" as MessageId, - targetText: "maximize right panel", - }), - }); - - try { - const terminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - const rightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find right panel toggle.", - ); - const chatHeader = await waitForElement( - () => document.querySelector("[data-chat-header]"), - "Unable to find chat header.", - ); - const panelLayoutControls = await waitForElement( - () => document.querySelector("[data-panel-layout-controls]"), - "Unable to find panel layout controls.", - ); - expect(chatHeader.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().top).toBe( - chatHeader.getBoundingClientRect().top, - ); - expect( - window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect(chatHeader.classList.contains("drag-region")).toBe(false); - expect(chatHeader.contains(panelLayoutControls)).toBe(true); - expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); - const initialTerminalRect = terminalToggle.getBoundingClientRect(); - const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); - const initialControlRects = [initialTerminalRect, initialRightPanelRect]; - expect(document.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); - expect(initialControlRects.every((rect) => rect.width === 28 && rect.height === 28)).toBe( - true, - ); - expect(initialControlRects.every((rect) => rect.top === initialControlRects[0]?.top)).toBe( - true, - ); - expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); - - document.documentElement.classList.add("wco"); - expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); - expect(panelLayoutControls.getBoundingClientRect().top).toBe( - chatHeader.getBoundingClientRect().top, - ); - expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); - document.documentElement.classList.remove("wco"); - - rightPanelToggle.click(); - - const maximizeButton = await waitForElement( - () => document.querySelector('button[aria-label="Maximize panel"]'), - "Unable to find maximize panel button.", - ); - const rightPanelTabbar = await waitForElement( - () => document.querySelector("[data-right-panel-tabbar]"), - "Unable to find right panel tab bar.", - ); - const rightPanelTabList = await waitForElement( - () => document.querySelector("[data-right-panel-tab-list]"), - "Unable to find right panel tab list.", - ); - const maximizeRect = maximizeButton.getBoundingClientRect(); - const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); - const openPanelLayoutControls = await waitForElement( - () => document.querySelector("[data-panel-layout-controls]"), - "Unable to find open panel layout controls.", - ); - const openTerminalToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find open panel terminal toggle.", - ); - const openRightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find open panel right panel toggle.", - ); - expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); - expect(rightPanelTabbarRect.height).toBe(52); - expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); - expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); - expect( - window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), - ).not.toBe("drag"); - expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); - expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( - "no-drag", - ); - expect( - window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect( - window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), - ).toBe("no-drag"); - expect(maximizeRect.width).toBe(28); - expect(maximizeRect.height).toBe(28); - expect(maximizeRect.top).toBe(initialTerminalRect.top); - expect(initialTerminalRect.left - maximizeRect.right).toBe(4); - expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( - initialTerminalRect.left, - 1, - ); - expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( - initialRightPanelRect.left, - 1, - ); - - useRightPanelStore.getState().openFile(THREAD_REF, "components.json"); - const fileTabIcon = await waitForElement( - () => - document.querySelector( - '[data-right-panel-tabbar] [data-pierre-icon][data-icon-token="json"]', - ), - "Unable to find the Pierre file icon in the file tab.", - ); - expect(fileTabIcon.closest("button")?.textContent).toContain("components.json"); - - document.documentElement.classList.add("wco"); - expect(rightPanelTabbar.getBoundingClientRect().height).toBe( - openPanelLayoutControls.getBoundingClientRect().height, - ); - expect(rightPanelTabbar.getBoundingClientRect().top).toBe( - openPanelLayoutControls.getBoundingClientRect().top, - ); - document.documentElement.classList.remove("wco"); - - maximizeButton.click(); - - await vi.waitFor(() => { - const chatColumn = document.querySelector( - '[data-chat-column-maximized-away="true"]', - ); - const panel = document.querySelector( - '[data-preview-panel-mode="inline"][data-preview-panel-maximized="true"]', - ); - expect(chatColumn?.getBoundingClientRect().width).toBe(0); - expect(panel?.getBoundingClientRect().width).toBeGreaterThan(1_000); - expect( - document.querySelector('button[aria-label="Restore panel size"]'), - ).not.toBeNull(); - expect( - document - .querySelector('button[aria-label="Toggle terminal drawer"]') - ?.getBoundingClientRect().left, - ).toBeCloseTo(initialTerminalRect.left, 1); - expect( - document - .querySelector('button[aria-label="Toggle right panel"]') - ?.getBoundingClientRect().left, - ).toBeCloseTo(initialRightPanelRect.left, 1); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the plan surface in the inline right panel", async () => { - useRightPanelStore.getState().open(THREAD_REF, "plan"); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-inline-plan-panel" as MessageId, - targetText: "show the inline plan panel", - }), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("p")).find( - (element) => element.textContent?.trim() === "No active plan yet.", - ) ?? null, - "Unable to find inline plan panel content.", - ); - - expect( - document.querySelector("[data-right-panel-tabbar]")?.textContent, - ).toContain("Plan"); - expect(document.body.textContent).toContain("Plans will appear here when generated."); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the shared panel toggles in the responsive right-panel sheet", async () => { - useRightPanelStore.getState().open(THREAD_REF, "plan"); - useRightPanelStore.getState().openTerminal(THREAD_REF, DEFAULT_TERMINAL_ID); - useRightPanelStore.getState().activateSurface(THREAD_REF, "plan"); - const baseSnapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-responsive-plan-panel-controls" as MessageId, - targetText: "show responsive plan panel controls", - }); - const snapshot: OrchestrationReadModel = { - ...baseSnapshot, - threads: baseSnapshot.threads.map((thread) => - thread.id === THREAD_ID - ? { - ...thread, - activities: [ - { - id: EventId.make("activity-responsive-panel-plan"), - tone: "info", - kind: "turn.plan.updated", - summary: "Plan updated", - payload: { - explanation: "Claude Tasks", - plan: [{ step: "Keep terminal navigation available", status: "inProgress" }], - }, - turnId: null, - sequence: 1, - createdAt: isoAt(1_000), - }, - ], - } - : thread, - ), - }; - - const mounted = await mountChatView({ - viewport: COMPACT_FOOTER_VIEWPORT, - snapshot, - }); - - try { - const sheet = await waitForElement( - () => document.querySelector('[data-slot="sheet-popup"]'), - "Unable to find responsive right-panel sheet.", - ); - const controls = await waitForElement( - () => sheet.querySelector("[data-panel-layout-controls]"), - "Unable to find shared controls in the responsive right-panel sheet.", - ); - const tabbar = await waitForElement( - () => sheet.querySelector("[data-right-panel-tabbar]"), - "Unable to find responsive right-panel tabbar.", - ); - const controlButtons = Array.from(controls.querySelectorAll("button")); - const tabbarRect = tabbar.getBoundingClientRect(); - const controlsRect = controls.getBoundingClientRect(); - - expect(controlButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ - "Toggle terminal drawer", - "Toggle right panel", - ]); - expect(tabbarRect.height).toBe(52); - expect(controlsRect.height).toBe(52); - expect(controlsRect.top).toBe(tabbarRect.top); - expect(window.innerWidth - controlsRect.right).toBe(12); - for (const button of controlButtons) { - const rect = button.getBoundingClientRect(); - const buttonCenter = rect.top + rect.height / 2; - const tabbarCenter = tabbarRect.top + tabbarRect.height / 2; - expect(rect.width).toBe(32); - expect(rect.height).toBe(32); - expect(Math.abs(buttonCenter - tabbarCenter)).toBeLessThanOrEqual(1); - } - expect( - controlButtons[1]!.getBoundingClientRect().left - - controlButtons[0]!.getBoundingClientRect().right, - ).toBe(4); - expect(sheet.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); - expect(sheet.querySelector('button[aria-label="Close tasks sidebar"]')).toBeNull(); - - const terminalTab = Array.from( - sheet.querySelectorAll("[data-right-panel-tab-list] button"), - ).find((button) => button.textContent?.includes("Terminal")); - terminalTab?.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .activeSurfaceId, - ).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); - expect(sheet.querySelector('[data-terminal-owner="right-panel"]')).not.toBeNull(); - expect(sheet.textContent).not.toContain("Claude Tasks"); - }); - - sheet.querySelector('button[aria-label="Close Plan"]')?.click(); - - await vi.waitFor(() => { - const panelState = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - THREAD_REF, - ); - expect(panelState.surfaces.some((surface) => surface.kind === "plan")).toBe(false); - expect(panelState.activeSurfaceId).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); - }); - - controls.querySelector('button[aria-label="Toggle right panel"]')?.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF).isOpen, - ).toBe(false); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("loads file previews from the active thread worktree", async () => { - const worktreePath = "/repo/worktrees/file-preview-thread"; - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-worktree-file-preview" as MessageId, - targetText: "open the worktree file preview", - }); - const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); - if (!targetThread) { - throw new Error("Missing target thread fixture."); - } - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, - ), - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.projectsListEntries) { - return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; - } - if (body._tag === WS_METHODS.projectsReadFile) { - return { - relativePath: "src/index.ts", - contents: "export const worktree = true;\n", - byteLength: 30, - truncated: false, - }; - } - return undefined; - }, - }); - - try { - useRightPanelStore.getState().open(THREAD_REF, "files"); - await waitForElement( - () => document.querySelector("[data-file-browser-panel]"), - "Unable to find the worktree file explorer.", - ); - - useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); - await waitForElement( - () => document.querySelector(".file-preview-virtualizer"), - "Unable to find the worktree file preview.", - ); - - const listRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.projectsListEntries, - ); - const readRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.projectsReadFile, - ); - expect(listRequest).toMatchObject({ cwd: worktreePath }); - expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); - } finally { - await mounted.cleanup(); - } - }); - - it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { - const workspaceEntries = [ - { path: "src", kind: "directory" as const }, - { path: "src/index.ts", kind: "file" as const }, - { path: "src/router.ts", kind: "file" as const }, - { path: "src/store.ts", kind: "file" as const }, - { path: "src/styles.css", kind: "file" as const }, - { path: "src/large.ts", kind: "file" as const }, - { path: "e2e", kind: "directory" as const }, - { path: "e2e/test-results", kind: "directory" as const }, - { - path: "e2e/test-results/playwright-integration-results", - kind: "directory" as const, - }, - { - path: "e2e/test-results/playwright-integration-results/chromium-desktop-project", - kind: "directory" as const, - }, - { - path: "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", - kind: "file" as const, - }, - { path: "README.md", kind: "file" as const }, - { path: "AGENTS.md", kind: "file" as const }, - { path: "package.json", kind: "file" as const }, - { path: "tsconfig.json", kind: "file" as const }, - ]; - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-file-tabs-and-tree-state" as MessageId, - targetText: "keep file tabs readable and preserve tree state", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.projectsListEntries) { - return { entries: workspaceEntries, truncated: false }; - } - if (body._tag === WS_METHODS.projectsReadFile) { - const relativePath = - typeof body.relativePath === "string" ? body.relativePath : "file.ts"; - const contents = - relativePath === "src/large.ts" - ? Array.from( - { length: 5_000 }, - (_, index) => `export const line${index + 1} = ${index + 1};`, - ).join("\n") - : `// ${relativePath}\n`; - return { - relativePath, - contents, - byteLength: new TextEncoder().encode(contents).byteLength, - truncated: false, - }; - } - return undefined; - }, - }); - - try { - useRightPanelStore.getState().open(THREAD_REF, "files"); - - const explorer = await waitForElement( - () => document.querySelector("[data-file-browser-panel]"), - "Unable to find the workspace file explorer.", - ); - - for (const entry of workspaceEntries) { - if (entry.kind === "file") { - useRightPanelStore.getState().openFile(THREAD_REF, entry.path); - } - } - - const tabList = await waitForElement( - () => document.querySelector("[data-right-panel-tab-list]"), - "Unable to find the right panel tab list.", - ); - const tabViewport = await waitForElement( - () => tabList.querySelector('[data-slot="scroll-area-viewport"]'), - "Unable to find the right panel tab viewport.", - ); - - await vi.waitFor(() => { - const fileTabs = Array.from(tabList.querySelectorAll("[data-active-tab]")); - expect(fileTabs.length).toBe( - workspaceEntries.filter((entry) => entry.kind === "file").length, - ); - expect(tabViewport.scrollWidth).toBeGreaterThan(tabViewport.clientWidth); - expect(tabViewport.scrollLeft).toBeGreaterThan(0); - expect(tabList.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); - expect( - fileTabs.every((tab) => { - const width = tab.getBoundingClientRect().width; - return width >= 100 && width <= 176; - }), - ).toBe(true); - expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); - }); - - useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); - await vi.waitFor(() => { - expect(document.querySelector("[data-file-browser-panel]")).toBe(explorer); - }); - - useRightPanelStore - .getState() - .openFile( - THREAD_REF, - "e2e/test-results/playwright-integration-results/chromium-desktop-project/.last-run.json", - ); - await mounted.setContainerSize({ width: 800, height: WIDE_FOOTER_VIEWPORT.height }); - const breadcrumbs = await waitForElement( - () => document.querySelector("[data-file-breadcrumbs]"), - "Unable to find the responsive file breadcrumbs.", - ); - const fileSubheader = breadcrumbs.closest("[data-surface-subheader]"); - const breadcrumbViewport = await waitForElement( - () => breadcrumbs.querySelector('[data-slot="scroll-area-viewport"]'), - "Unable to find the file breadcrumb viewport.", - ); - const currentCrumb = await waitForElement( - () => - Array.from( - breadcrumbs.querySelectorAll("[data-current-file-crumb='true']"), - ).find((crumb) => crumb.textContent === ".last-run.json") ?? null, - "Unable to find the current file breadcrumb.", - ); - const explorerToggle = await waitForElement( - () => document.querySelector('button[aria-label="Hide file explorer"]'), - "Unable to find the file explorer toggle.", - ); - - await vi.waitFor(() => { - const viewportRect = breadcrumbViewport.getBoundingClientRect(); - const currentCrumbRect = currentCrumb.getBoundingClientRect(); - expect(breadcrumbViewport.scrollWidth).toBeGreaterThan(breadcrumbViewport.clientWidth); - expect(breadcrumbViewport.scrollLeft).toBeGreaterThan(0); - expect(breadcrumbs.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull(); - expect(currentCrumbRect.right).toBeLessThanOrEqual(viewportRect.right + 1); - expect(viewportRect.right).toBeLessThan(explorerToggle.getBoundingClientRect().left); - expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); - expect(explorerToggle.getBoundingClientRect().width).toBe(28); - expect(explorerToggle.getBoundingClientRect().height).toBe(28); - expect(fileSubheader?.getBoundingClientRect().height).toBe(40); - expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); - expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); - }); - - const fileSearchButton = await waitForElement( - () => - document.querySelector('button[aria-label="Search workspace files"]'), - "Unable to find the workspace file search button.", - ); - fileSearchButton.click(); - const fileTree = await waitForElement( - () => document.querySelector("file-tree-container"), - "Unable to find the file tree host.", - ); - const fileSearchInput = await waitForElement( - () => - fileTree.shadowRoot?.querySelector("[data-file-tree-search-input]") ?? - null, - "Unable to find the file tree search input.", - ); - fileSearchInput.focus(); - const searchKeyEvent = new KeyboardEvent("keydown", { - key: "r", - bubbles: true, - cancelable: true, - composed: true, - }); - fileSearchInput.dispatchEvent(searchKeyEvent); - await waitForLayout(); - expect(searchKeyEvent.defaultPrevented).toBe(false); - expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - - const previousCodeVirtualizer = document.querySelector( - ".file-preview-virtualizer", - ); - useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); - const codeVirtualizer = await waitForElement(() => { - const current = document.querySelector(".file-preview-virtualizer"); - return current !== previousCodeVirtualizer ? current : null; - }, "Unable to find the virtualized file preview."); - expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); - expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); - await vi.waitFor( - () => { - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLine = fileHost?.shadowRoot?.querySelector('[data-line="4000"]'); - const targetLineNumber = fileHost?.shadowRoot?.querySelector( - '[data-column-number="4000"]', - ); - const previousLine = - fileHost?.shadowRoot?.querySelector('[data-line="3999"]'); - const previousLineNumber = fileHost?.shadowRoot?.querySelector( - '[data-column-number="3999"]', - ); - expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); - expect(targetLine).not.toBeNull(); - expect(previousLine).not.toBeNull(); - expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLineNumber?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); - expect(targetLineNumber?.hasAttribute("data-selected-line")).toBe(false); - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); - expect(window.getComputedStyle(targetLine!).backgroundColor).not.toBe( - window.getComputedStyle(previousLine!).backgroundColor, - ); - expect(window.getComputedStyle(targetLineNumber!).backgroundColor).not.toBe( - window.getComputedStyle(previousLineNumber!).backgroundColor, - ); - - const viewportRect = codeVirtualizer.getBoundingClientRect(); - const lineRect = targetLine!.getBoundingClientRect(); - expect(lineRect.top).toBeGreaterThanOrEqual(viewportRect.top); - expect(lineRect.bottom).toBeLessThanOrEqual(viewportRect.bottom); - }, - { timeout: 8_000, interval: 16 }, - ); - - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLineNumber = - fileHost?.shadowRoot?.querySelector('[data-column-number="4000"]') ?? null; - const previousLineNumber = - fileHost?.shadowRoot?.querySelector('[data-column-number="3999"]') ?? null; - expect(targetLineNumber).not.toBeNull(); - expect(previousLineNumber).not.toBeNull(); - - targetLineNumber!.dispatchEvent( - new PointerEvent("pointermove", { - bubbles: true, - cancelable: true, - composed: true, - pointerType: "mouse", - }), - ); - await vi.waitFor(() => { - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); - }); - - previousLineNumber!.dispatchEvent( - new PointerEvent("pointermove", { - bubbles: true, - cancelable: true, - composed: true, - pointerType: "mouse", - }), - ); - await vi.waitFor(() => { - expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); - expect(previousLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); - }); - - codeVirtualizer.scrollTop = 0; - useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); - await vi.waitFor( - () => { - const fileHost = codeVirtualizer.querySelector("diffs-container"); - const targetLine = fileHost?.shadowRoot?.querySelector('[data-line="4000"]'); - expect(targetLine).not.toBeNull(); - expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); - expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); - expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("removes persisted file tabs when a draft workspace no longer exists", async () => { - const orphanedDraftId = DraftId.make("draft-orphaned-file-panel"); - const orphanedThreadId = "thread-orphaned-file-panel" as ThreadId; - const orphanedThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, orphanedThreadId); - useComposerDraftStore.getState().setProjectDraftThreadId( - { - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: "project-deleted" as ProjectId, - }, - orphanedDraftId, - { threadId: orphanedThreadId }, - ); - useRightPanelStore.getState().openFile(orphanedThreadRef, "conductor.json"); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-orphaned-file-panel" as MessageId, - targetText: "orphaned persisted file panel", - }), - initialPath: `/draft/${orphanedDraftId}`, - }); - - try { - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, orphanedThreadRef), - ).toEqual({ - isOpen: false, - activeSurfaceId: null, - surfaces: [], - }); - expect(document.querySelector("[data-right-panel-tabbar]")).toBeNull(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, - targetText: "open inline terminal panel", - }), - }); - - try { - const rightPanelToggle = await waitForElement( - () => document.querySelector('button[aria-label="Toggle right panel"]'), - "Unable to find right panel toggle.", - ); - rightPanelToggle.click(); - - await vi.waitFor(() => { - expect(document.body.textContent).toContain("Open a surface"); - }); - expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: null, - surfaces: [], - }); - expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); - - const emptyStateTerminalButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes("Start a shell in this workspace."), - ) ?? null, - "Unable to find the empty-state Terminal button.", - ); - emptyStateTerminalButton.click(); - - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .surfaces.filter((surface) => surface.kind === "terminal") - .map((surface) => surface.resourceId), - ).toEqual(["term-1"]); - }); - - const addSurface = await waitForElement( - () => document.querySelector('button[aria-label="Add panel surface"]'), - "Unable to find add panel surface button beside the tabs.", - ); - addSurface.click(); - const secondTerminalItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[role="menuitem"]')).find( - (item) => item.textContent?.trim() === "Terminal", - ) ?? null, - "Unable to find Terminal panel menu item.", - ); - secondTerminalItem.click(); - - await vi.waitFor( - () => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) - .surfaces.filter((surface) => surface.kind === "terminal") - .map((surface) => surface.resourceId), - ).toEqual(["term-1", "term-2"]); - expect( - document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), - ).not.toBeNull(); - expect( - wsRequests - .filter((request) => request._tag === WS_METHODS.terminalOpen) - .map((request) => ("terminalId" in request ? request.terminalId : null)), - ).toEqual(expect.arrayContaining(["term-1", "term-2"])); - const attachRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalAttach && - "terminalId" in request && - request.terminalId === "term-2", - ); - expect(attachRequest).toMatchObject({ - _tag: WS_METHODS.terminalAttach, - threadId: THREAD_ID, - terminalId: "term-2", - cwd: "/repo/project", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - const drawerToggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", - ); - drawerToggle.click(); - - await vi.waitFor(() => { - expect( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], - ).toMatchObject({ - terminalOpen: true, - terminalIds: ["term-3"], - }); - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalAttach && - "terminalId" in request && - request.terminalId === "term-3", - ), - ).toBe(true); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode-insiders", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the project cwd with Trae when it is the only available editor", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["trae"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "trae", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["kiro"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Copy options"]'), - "Unable to find Open picker button.", - ); - (menuButton as HTMLButtonElement).click(); - - const kiroItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("Kiro"), - ) ?? null, - "Unable to find Kiro menu item.", - ); - (kiroItem as HTMLElement).click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "kiro", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("filters the open picker menu and opens VSCodium from the menu", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders", "vscodium"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const menuButton = await waitForElement( - () => document.querySelector('button[aria-label="Copy options"]'), - "Unable to find Open picker button.", - ); - (menuButton as HTMLButtonElement).click(); - - await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("VS Code Insiders"), - ) ?? null, - "Unable to find VS Code Insiders menu item.", - ); - - expect( - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => - item.textContent?.includes("Zed"), - ), - ).toBe(false); - - const vscodiumItem = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => - item.textContent?.includes("VSCodium"), - ) ?? null, - "Unable to find VSCodium menu item.", - ); - (vscodiumItem as HTMLElement).click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscodium", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to the first installed editor when the stored favorite is unavailable", async () => { - localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - availableEditors: ["vscode-insiders"], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const openButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Open", - ) as HTMLButtonElement | null, - "Unable to find Open button.", - ); - await vi.waitFor(() => { - expect(openButton.disabled).toBe(false); - }); - openButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.shellOpenInEditor, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.shellOpenInEditor, - cwd: "/repo/project", - editor: "vscode-insiders", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("runs project scripts from local draft threads at the project cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "lint", - name: "Lint", - command: "bun run lint", - icon: "lint", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.getAttribute("aria-label") === "Run Lint", - ) as HTMLButtonElement | null, - "Unable to find Run Lint button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/project", - 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("uses the configured diff toggle binding without discarding its surface", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-diff-hotkey" as MessageId, - targetText: "diff hotkey target", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "diff.toggle", - shortcut: { - key: "g", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: true, - modKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: false, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - - dispatchConfiguredDiffToggleShortcut(); - await vi.waitFor(() => { - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), - ).toEqual({ - isOpen: true, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("focuses the composer and inserts printable text typed from the page background", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-type-to-focus" as MessageId, - targetText: "type-to-focus target", - }), - }); - - const backgroundTarget = document.createElement("div"); - backgroundTarget.tabIndex = -1; - document.body.append(backgroundTarget); - - try { - const composerEditor = await waitForComposerEditor(); - backgroundTarget.focus(); - expect(document.activeElement).not.toBe(composerEditor); - - const event = new KeyboardEvent("keydown", { - key: "h", - bubbles: true, - cancelable: true, - }); - backgroundTarget.dispatchEvent(event); - - await waitForComposerText("h"); - expect(event.defaultPrevented).toBe(true); - expect(document.activeElement).toBe(composerEditor); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "i", - bubbles: true, - cancelable: true, - }), - ); - - await waitForComposerText("hi"); - } finally { - backgroundTarget.remove(); - await mounted.cleanup(); - } - }); - - it("does not steal printable keys from editable targets or shortcut modifiers", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-type-to-focus-guards" as MessageId, - targetText: "type-to-focus guards target", - }), - }); - const input = document.createElement("input"); - document.body.append(input); - - try { - input.focus(); - input.dispatchEvent( - new KeyboardEvent("keydown", { - key: "x", - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "k", - metaKey: true, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); - } finally { - input.remove(); - await mounted.cleanup(); - } - }); - - it("uses the active draft route session when changing the base branch", async () => { - const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); - const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); - - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [staleDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - [activeDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, - [PROJECT_DRAFT_KEY]: activeDraftId, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - initialPath: `/draft/${activeDraftId}`, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 2, - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - { - name: "release/next", - current: false, - isDefault: false, - worktreePath: null, - }, - ], - }; - } - return undefined; - }, - }); - - try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "From main", - ) as HTMLButtonElement | null, - 'Unable to find branch selector button with "From main".', - ); - branchButton.click(); - - const branchOption = await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "release/next", - ) as HTMLSpanElement | null, - 'Unable to find the "release/next" branch option.', - ); - branchOption.click(); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( - "release/next", - ); - expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( - "main", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.trim().includes("From release/next"), - ); - expect(updatedButton).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { - const draftId = DraftId.make("draft-branch-picker-scroll-regression"); - const branches = [ - { - name: "feature/current", - current: true, - isDefault: false, - worktreePath: null, - }, - { - name: "main", - current: false, - isDefault: true, - worktreePath: null, - }, - ...Array.from({ length: 48 }, (_, index) => ({ - name: `feature/${String(index).padStart(2, "0")}`, - current: false, - isDefault: false, - worktreePath: null, - })), - { - name: "feature/selected", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [draftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature/selected", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: draftId, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - initialPath: `/draft/${draftId}`, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: branches.length, - refs: branches, - }; - } - return undefined; - }, - }); - - try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "From feature/selected", - ) as HTMLButtonElement | null, - 'Unable to find branch selector button with "From feature/selected".', - ); - branchButton.click(); - - await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), - "Unable to find ref search input.", - ); - - const popup = await waitForElement( - () => document.querySelector('[data-slot="combobox-popup"]'), - "Unable to find the branch picker popup.", - ); - - await vi.waitFor( - () => { - const popupSpans = Array.from(popup.querySelectorAll("span")); - expect( - popupSpans.some((element) => element.textContent?.trim() === "feature/current"), - ).toBe(true); - expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-basic" as MessageId, - targetText: "surround basic", - }), - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); - await pressComposerKey("("); - await waitForComposerText("(selected)"); - - await pressComposerKey("["); - await waitForComposerText("([selected])"); - } finally { - await mounted.cleanup(); - } - }); - - it("leaves collapsed-caret typing unchanged for surround symbols", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-collapsed" as MessageId, - targetText: "surround collapsed", - }), - }); - - try { - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ - start: "selected".length, - end: "selected".length, - }); - await pressComposerKey("("); - await waitForComposerText("selected("); - } finally { - await mounted.cleanup(); - } - }); - - it("supports symmetric and backward-selection surrounds", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-backward" as MessageId, - targetText: "surround backward", - }), - }); - - try { - await waitForComposerText("backward"); - await setComposerSelectionByTextOffsets({ - start: 0, - end: "backward".length, - direction: "backward", - }); - await pressComposerKey("*"); - await waitForComposerText("*backward*"); - } finally { - await mounted.cleanup(); - } - }); - - it("supports option-produced surround symbols like guillemets", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-guillemet" as MessageId, - targetText: "surround guillemet", - }), - }); - - try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); - await pressComposerKey("«"); - await waitForComposerText("«quoted»"); - } finally { - await mounted.cleanup(); - } - }); - - it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-dead-quote" as MessageId, - targetText: "surround dead quote", - }), - }); - - try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Dead", - bubbles: true, - cancelable: true, - }), - ); - composerEditor.dispatchEvent( - new InputEvent("beforeinput", { - data: "'", - inputType: "insertCompositionText", - bubbles: true, - cancelable: true, - }), - ); - const resolvedInputEvent = new InputEvent("beforeinput", { - data: "'", - inputType: "insertText", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(resolvedInputEvent); - expect(resolvedInputEvent.defaultPrevented).toBe(true); - await waitForComposerText("'quoted'"); - await pressComposerUndo(); - await waitForComposerText("quoted"); - } finally { - await mounted.cleanup(); - } - }); - - it("surrounds text after a mention using the correct expanded offsets", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-after-mention" as MessageId, - targetText: "surround after mention", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, - ); - await waitForComposerText("hi [package.json](package.json) there"); - await setComposerSelectionByTextOffsets({ - start: "hi package.json ".length, - end: "hi package.json there".length, - }); - await pressComposerKey("("); - await waitForComposerText("hi [package.json](package.json) (there)"); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to normal replacement when the selection includes a mention token", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there "); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-token" as MessageId, - targetText: "surround token", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, - ); - await selectAllComposerContent(); - await pressComposerKey("("); - await waitForComposerText("("); - } finally { - await mounted.cleanup(); - } - }); - - it("stores selected file tags as markdown links while keeping the composer chip", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "@pack"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-file-tag-encoding" as MessageId, - targetText: "file tag encoding", - }), - resolveRpc: (body) => { - if (body._tag !== WS_METHODS.projectsSearchEntries) { - return undefined; - } - return { - entries: [ - { - path: "path/to/package.json", - kind: "file", - }, - ], - truncated: false, - }; - }, - }); - - try { - const item = await waitForComposerMenuItem("path:file:path/to/package.json"); - item.click(); - - await waitForComposerText("[package.json](path/to/package.json) "); - const chip = await waitForElement( - () => document.querySelector('[data-composer-mention-chip="true"]'), - "Unable to find rendered composer file chip.", - ); - expect(chip.textContent).toContain("package.json"); - } finally { - await mounted.cleanup(); - } - }); - - it("shows runtime mode descriptions in the desktop composer access select", async () => { - setDraftThreadWithoutWorktree(); - - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - }); - - try { - const runtimeModeSelect = await waitForButtonByText("Full access"); - runtimeModeSelect.click(); - - expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( - "Ask before commands and file changes", - ); - - const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); - expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); - expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( - "Allow commands and edits without prompts", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps removed terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_REF, nextPrompt.prompt); - store.removeTerminalContext(THREAD_REF, "ctx-removed"); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), - ); - - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); - } finally { - await mounted.cleanup(); - } - }); - - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a pointer cursor for the running stop button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-stop-button-cursor" as MessageId, - targetText: "stop button cursor target", - sessionStatus: "running", - }), - }); - - try { - const stopButton = await waitForElement( - () => document.querySelector('button[aria-label="Stop generation"]'), - "Unable to find stop generation button.", - ); - - expect(getComputedStyle(stopButton).cursor).toBe("pointer"); - } finally { - await mounted.cleanup(); - } - }); - - it("hides the archive action when the pointer leaves a thread row", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-hover-test" as MessageId, - targetText: "archive hover target", - }), - }); - - try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - const archiveButton = await waitForElement( - () => - document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), - "Unable to find archive button.", - ); - const archiveAction = archiveButton.parentElement; - expect( - archiveAction, - "Archive button should render inside a visibility wrapper.", - ).not.toBeNull(); - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); - - await threadRow.hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("1"); - }, - { timeout: 4_000, interval: 16 }, - ); - - await page.getByTestId("composer-editor").hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); - }, - { timeout: 4_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("exposes the full thread title on the sidebar row tooltip", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-thread-tooltip-target" as MessageId, - targetText: "thread tooltip target", - }), - }); - - try { - const threadTitle = page.getByTestId(`thread-title-${THREAD_ID}`); - - await expect.element(threadTitle).toBeInTheDocument(); - await threadTitle.hover(); - - await vi.waitFor( - () => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain(THREAD_TITLE); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows the sidebar terminal indicator from terminal metadata activity", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId, - targetText: "terminal metadata indicator target", - }), - configureFixture: (nextFixture) => { - nextFixture.terminalMetadataEvents = [ - { - type: "upsert", - terminal: { - threadId: THREAD_ID, - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/repo/project", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: true, - label: "Terminal 1", - updatedAt: isoAt(1_200), - }, - }, - ]; - }, - }); - - try { - await vi.waitFor( - () => { - expect( - terminalSessionManager.listSessions({ - environmentId: LOCAL_ENVIRONMENT_ID, - threadId: THREAD_ID, - }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const terminalIndicator = document.querySelector( - '[aria-label="Terminal process running"]', - ); - expect(terminalIndicator).not.toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows the confirm archive action after clicking the archive button", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - confirmThreadArchive: true, - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-confirm-test" as MessageId, - targetText: "archive confirm target", - }), - }); - - try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - await threadRow.hover(); - - const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); - await expect.element(archiveButton).toBeInTheDocument(); - await archiveButton.click(); - - const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); - await expect.element(confirmButton).toBeInTheDocument(); - await expect.element(confirmButton).toBeVisible(); - } finally { - localStorage.removeItem("t3code:client-settings:v1"); - await mounted.cleanup(); - } - }); - - it("canonicalizes promoted draft threads to the server thread route", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-test" as MessageId, - targetText: "new thread selection test", - }), - }); - - try { - // Wait for the sidebar to render with the project. - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - // The route should change to a new draft thread ID. - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); - - // The composer editor should be present for the new draft thread. - await waitForComposerEditor(); - - // `thread.created` should only mark the draft as promoting; it should - // not navigate away until the server thread has actual runtime state. - await materializePromotedDraftThreadViaDomainEvent(newThreadId); - expect(mounted.router.state.location.pathname).toBe(newThreadPath); - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - - // Once the server thread starts, the route should canonicalize. - await startPromotedServerThreadViaDomainEvent(newThreadId); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( - undefined, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - // The route should switch to the canonical server thread path. - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Promoted drafts should canonicalize to the server thread route.", - ); - - // The composer should remain usable after canonicalization, regardless of - // whether the promoted thread is still visibly empty or has already - // entered the running state. - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("canonicalizes stale promoted draft routes to the server thread route", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, - targetText: "draft hydration race test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); - - await promoteDraftThreadViaDomainEvent(newThreadId); - - await mounted.router.navigate({ - to: "/draft/$draftId", - params: { draftId: newDraftId }, - }); - - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Stale promoted draft routes should canonicalize to the server thread path.", - ); - - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, - targetText: "new thread worktree default test", - }), - threads: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, - targetText: "new thread worktree default test", - }).threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }) - : thread, - ), - }, - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - }; - }, - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should change to a new draft thread.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ - envMode: "worktree", - worktreePath: null, - }); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new draft instead of reusing a promoting draft thread", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, - targetText: "promoting draft new thread test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const firstDraftPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should change to the first draft thread.", - ); - const firstDraftId = draftIdFromPath(firstDraftPath); - const firstThreadId = draftThreadIdFor(firstDraftId); - - await materializePromotedDraftThreadViaDomainEvent(firstThreadId); - expect(mounted.router.state.location.pathname).toBe(firstDraftPath); - - await newThreadButton.click(); - - const secondDraftPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, - "Route should change to a second draft thread instead of reusing the promoting draft.", - ); - expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); - } finally { - await mounted.cleanup(); - } - }); - - it("snapshots sticky codex settings into a new draft thread", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("codex"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, - targetText: "sticky codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - // `toMatchObject` matches objects loosely (extras ignored) but compares - // arrays strictly, so wrap `options` in `arrayContaining` to keep the - // assertion focused on sticky `fastMode` carrying over without asserting - // on exactly which other options are preserved. - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("hydrates the provider alongside a sticky claude model", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("claudeAgent")]: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, - targetText: "sticky claude model test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new sticky claude draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - claudeAgent: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - ), - }, - activeProvider: "claudeAgent", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("falls back to defaults when no sticky composer settings exist", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-default-codex-traits-test" as MessageId, - targetText: "default codex traits test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - - expect(composerDraftFor(newDraftId)).toBe(undefined); - } finally { - await mounted.cleanup(); - } - }); - - it("prefers draft state over sticky composer settings and defaults", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - [ProviderInstanceId.make("codex")]: createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.3-codex", - [ - { id: "reasoningEffort", value: "medium" }, - { id: "fastMode", value: true }, - ], - ), - }, - stickyActiveProvider: ProviderInstanceId.make("codex"), - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, - targetText: "draft codex traits precedence test", - }), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - - await newThreadButton.click(); - - const threadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a sticky draft thread UUID.", - ); - const draftId = draftIdFromPath(threadPath); - - // See the note on the sibling sticky-codex test: arrays match strictly - // under `toMatchObject`, so use `arrayContaining` to keep the assertion - // scoped to the sticky trait (`fastMode`) that must carry over. - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); - - useComposerDraftStore.getState().setModelSelection( - draftId, - createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "low" }, - { id: "fastMode", value: true }, - ]), - ); - - await newThreadButton.click(); - - await waitForURL( - mounted.router, - (path) => path === threadPath, - "New-thread should reuse the existing project draft thread.", - ); - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "low" }, - { id: "fastMode", value: true }, - ]), - }, - activeProvider: "codex", - }); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new thread from the global chat.new shortcut", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "thread.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - }; - }, - }); - - try { - await waitForNewThreadShortcutLabel(); - await waitForServerConfigToApply(); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - await triggerChatNewShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the shortcut.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not consume chat.new when there is no project context", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createProjectlessSnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - dispatchChatNewShortcut(); - await waitForLayout(); - - expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); - expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); - } finally { - await mounted.cleanup(); - } - }); - - it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, - targetText: "command palette shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("New thread in Project", { exact: true }).click(); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the command palette.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("filters command palette results as the user types", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-search-test" as MessageId, - targetText: "command palette search test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); - await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .not.toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("adds a project from browse mode with Enter when no directory is highlighted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, - targetText: "command palette add project enter", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [ - { name: "alpha", fullPath: "~/Development/alpha" }, - { name: "beta", fullPath: "~/Development/beta" }, - ], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); - await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); - - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development", - title: "Development", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project with Enter.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows clone destination controls after resolving an add project repository", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, - targetText: "command palette add project remote", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === WS_METHODS.sourceControlLookupRepository) { - return { - provider: "github", - nameWithOwner: "t3-oss/t3-env", - url: "https://github.com/t3-oss/t3-env", - sshUrl: "git@github.com:t3-oss/t3-env.git", - }; - } - - if (body._tag === WS_METHODS.sourceControlCloneRepository) { - return { - cwd: body.destinationPath, - remoteUrl: body.remoteUrl, - repository: null, - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("GitHub repository", { exact: true }).click(); - - const repositoryInput = await waitForCommandPaletteInput( - "Enter GitHub repository (owner/repo)", - ); - await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); - await dispatchInputKey(repositoryInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const clonePathInput = document.querySelector( - 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', - ); - expect(clonePathInput?.value).toBe("~/"); - expect(document.body.textContent).toContain("Repository"); - expect(document.body.textContent).toContain("t3-oss/t3-env"); - expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); - expect(document.body.textContent).toContain("Select where to clone"); - expect(document.body.textContent).toContain("Development"); - expect(document.body.textContent).toContain("Clone"); - }, - { timeout: 8_000, interval: 16 }, - ); - - await page - .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") - .fill("~/Development/t3env"); - const clonePathInput = await waitForCommandPaletteInput( - "Enter path (e.g. ~/projects/my-app)", - ); - await dispatchInputKey(clonePathInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const cloneRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.sourceControlCloneRepository, - ) as { destinationPath?: string; remoteUrl?: string } | undefined; - expect(cloneRequest).toMatchObject({ - remoteUrl: "git@github.com:t3-oss/t3-env.git", - destinationPath: "~/Development/t3env", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens add project browse mode from the sidebar add button", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, - targetText: "sidebar add project trigger", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("starts add project browse mode from the configured base directory", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, - targetText: "sidebar add project custom base directory", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - addProjectBaseDirectory: "~/Development", - }, - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [{ name: "codething", fullPath: "~/Development/codething" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/Development/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && - request.partialPath === "~/Development/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows create-folder affordances for missing project paths", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, - targetText: "command palette create missing project", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Desktop/") { - return { - parentPath: "~/Desktop/", - entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Desktop", fullPath: "~/Desktop" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - const palette = page.getByTestId("command-palette"); - await page.getByTestId("sidebar-add-project-trigger").click(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); - - await expect - .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) - .toBeInTheDocument(); - await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - createWorkspaceRootIfMissing?: boolean; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Desktop/fresh-project", - title: "fresh-project", - createWorkspaceRootIfMissing: true, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("does not show create affordances for an existing directory with a trailing slash", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, - targetText: "command palette existing trailing directory", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/codex/") { - return { - parentPath: "~/Development/codex/", - entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - const palette = page.getByTestId("command-palette"); - await page.getByTestId("sidebar-add-project-trigger").click(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.filesystemBrowse && - request.partialPath === "~/Development/codex/", - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - await expect - .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) - .not.toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development/codex", - title: "codex", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("selects an environment before browsing when multiple environments are available", async () => { - const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { - if (partialPath === "~/workspaces/") { - return { - parentPath: "~/workspaces/", - entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "workspaces", fullPath: "~/workspaces" }], - }; - }); - const remoteDispatchMock = vi.fn(async () => ({ - sequence: fixture.snapshot.snapshotSequence + 1, - })); - - __setEnvironmentApiOverrideForTests( - REMOTE_ENVIRONMENT_ID, - createMockEnvironmentApi({ - browse: remoteBrowseMock, - dispatchCommand: remoteDispatchMock, - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, - targetText: "command palette add project multi env", - }), - }); - - try { - await waitForServerConfigToApply(); - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - httpBaseUrl: "https://staging.example.test", - wsBaseUrl: "wss://staging.example.test/ws", - createdAt: NOW_ISO, - lastConnectedAt: NOW_ISO, - }); - useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { - connectionState: "connected", - authState: "authenticated", - descriptor: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - serverConfig: { - ...fixture.serverConfig, - environment: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - settings: { - ...fixture.serverConfig.settings, - addProjectBaseDirectory: "~/workspaces", - }, - }, - connectedAt: NOW_ISO, - }); - - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("This device", { exact: true }).first()) - .toBeInTheDocument(); - await palette.getByText("Staging", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/workspaces/"); - - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - expect(remoteDispatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: "project.create", - workspaceRoot: "~/workspaces", - title: "workspaces", - }), - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a remote project.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("picks a local project from the native file manager", async () => { - const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, - targetText: "command palette add project file manager", - }), - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Applications/") { - return { - parentPath: "~/Applications/", - entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Applications", fullPath: "~/Applications" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - window.desktopBridge = { - pickFolder, - setTheme: vi.fn().mockResolvedValue(undefined), - } as unknown as NonNullable; - - await page.getByTestId("sidebar-add-project-trigger").click(); - - const palette = page.getByTestId("command-palette"); - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Local folder", { exact: true }).click(); - const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await browseInput.fill("~/Applications/access"); - - const fileManagerLabel = isMacPlatform(navigator.platform) - ? "Open in Finder" - : navigator.platform.toLowerCase().startsWith("win") - ? "Open in Explorer" - : "Open in Files"; - await palette.getByRole("button", { name: fileManagerLabel }).click(); - - await vi.waitFor( - () => { - expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "/Users/julius/Projects/finder-picked", - title: "finder-picked", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project from the native file manager.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, - targetText: "command palette add project mod enter", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - resolveRpc: (body) => { - if (body._tag === WS_METHODS.filesystemBrowse) { - if (body.partialPath === "~/Development/") { - return { - parentPath: "~/Development/", - entries: [ - { name: "alpha", fullPath: "~/Development/alpha" }, - { name: "beta", fullPath: "~/Development/beta" }, - ], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "Development", fullPath: "~/Development" }], - }; - } - - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - - return undefined; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); - await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "ArrowDown" }); - - const addButtonLabel = isMacPlatform(navigator.platform) - ? "Add (\u2318 Enter)" - : "Add (Ctrl Enter)"; - await vi.waitFor( - () => { - const legendEntries = getCommandPaletteLegendEntries(); - expect(legendEntries).toContain("Enter Select"); - }, - { timeout: 8_000, interval: 16 }, - ); - await expect - .element(palette.getByRole("button", { name: addButtonLabel })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { - key: "Enter", - metaKey: isMacPlatform(navigator.platform), - ctrlKey: !isMacPlatform(navigator.platform), - }); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "project.create", - ) as - | { - _tag: string; - type?: string; - workspaceRoot?: string; - title?: string; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "project.create", - workspaceRoot: "~/Development", - title: "Development", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a project with Mod+Enter.", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps project-context thread matches available when searching by project name", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("Release checklist", { exact: true })) - .toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("searches projects by path and opens the latest thread for that project", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("Docs Portal", { exact: true }).click(); - - const nextPath = await waitForURL( - mounted.router, - (path) => path === serverThreadPath("thread-secondary-project" as ThreadId), - "Route should have changed to the latest thread for the selected project.", - ); - expect(nextPath).toBe(serverThreadPath("thread-secondary-project" as ThreadId)); - expect( - useComposerDraftStore - .getState() - .getDraftThread(threadRefFor("thread-secondary-project" as ThreadId)), - ).toBeNull(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a new thread from project search when no active project thread exists", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - settings: { - ...nextFixture.serverConfig.settings, - defaultThreadEnvMode: "worktree", - }, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); - await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("Docs Portal", { exact: true }).click(); - - const nextPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the project search result.", - ); - const nextDraftId = draftIdFromPath(nextPath); - const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId); - expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); - expect(draftThread?.envMode).toBe("worktree"); - } finally { - await mounted.cleanup(); - } - }); - - it("filters archived threads out of command palette search results", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithSecondaryProject(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); - await expect - .element(palette.getByText("Archived Docs Notes", { exact: true })) - .not.toBeInTheDocument(); - } finally { - await mounted.cleanup(); - } - }); - - it("creates a fresh draft after the previous draft thread is promoted", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, - targetText: "promoted draft shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await waitForServerConfigToApply(); - await newThreadButton.click(); - - const promotedThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a promoted draft thread UUID.", - ); - const promotedDraftId = draftIdFromPath(promotedThreadPath); - const promotedThreadId = draftThreadIdFor(promotedDraftId); - - await promoteDraftThreadViaDomainEvent(promotedThreadId); - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(promotedThreadId), - "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", - ); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - - const freshThreadPath = await triggerChatNewShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, - "Shortcut should create a fresh draft instead of reusing the promoted thread.", - ); - expect(freshThreadPath).not.toBe(promotedThreadPath); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps long proposed plans lightweight until the user expands them", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithLongProposedPlan(), - }); - - try { - await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - - expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); - - const expandButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Expand plan", - ) as HTMLButtonElement | null, - "Unable to find Expand plan button.", - ); - expandButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("deep hidden detail only after expand"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("uses the active worktree path when saving a proposed plan to the workspace", async () => { - const snapshot = createSnapshotWithLongProposedPlan(); - const threads = snapshot.threads.slice(); - const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); - const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; - if (targetThread) { - threads[targetThreadIndex] = { - ...targetThread, - worktreePath: "/repo/worktrees/plan-thread", - }; - } - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - threads, - }, - }); - - try { - const planActionsButton = await waitForElement( - () => document.querySelector('button[aria-label="Plan actions"]'), - "Unable to find proposed plan actions button.", - ); - planActionsButton.click(); - - const saveToWorkspaceItem = await waitForElement( - () => - (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( - (item) => item.textContent?.trim() === "Save to workspace", - ) ?? null) as HTMLElement | null, - 'Unable to find "Save to workspace" menu item.', - ); - saveToWorkspaceItem.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Enter a path relative to /repo/worktrees/plan-thread.", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps pending-question footer actions inside the composer after a real resize", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput(), - }); - - try { - const firstOption = await waitForButtonContainingText("Tight"); - firstOption.click(); - - await waitForButtonByText("Previous"); - await waitForButtonByText("Submit answers"); - - await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); - await expectComposerActionsContained(); - } finally { - await mounted.cleanup(); - } - }); - - it("submits pending user input after the final option selection resolves the draft answers", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput(), - resolveRpc: (body) => { - if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return { - sequence: fixture.snapshot.snapshotSequence + 1, - }; - } - return undefined; - }, - }); - - try { - const firstOption = await waitForButtonContainingText("Tight"); - firstOption.click(); - - const finalOption = await waitForButtonContainingText("Conservative"); - finalOption.click(); - - await vi.waitFor( - () => { - const dispatchRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.type === "thread.user-input.respond", - ) as - | { - _tag: string; - type?: string; - requestId?: string; - answers?: Record; - } - | undefined; - - expect(dispatchRequest).toMatchObject({ - _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, - type: "thread.user-input.respond", - requestId: "req-browser-user-input", - answers: { - scope: "Tight", - risk: "Conservative", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt(), - }); - - try { - const footer = await waitForElement( - () => document.querySelector('[data-chat-composer-footer="true"]'), - "Unable to find composer footer.", - ); - const initialModelPicker = await waitForElement( - findComposerProviderModelPicker, - "Unable to find provider model picker.", - ); - const initialModelPickerOffset = - initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; - const initialImplementButton = await waitForButtonByText("Implement"); - const initialImplementWidth = initialImplementButton.getBoundingClientRect().width; - - await waitForElement( - () => - document.querySelector('button[aria-label="Implementation actions"]'), - "Unable to find implementation actions trigger.", - ); - - await mounted.setContainerSize({ - width: 440, - height: WIDE_FOOTER_VIEWPORT.height, - }); - await expectComposerActionsContained(); - - const implementButton = await waitForButtonByText("Implement"); - const implementActionsButton = await waitForElement( - () => - document.querySelector('button[aria-label="Implementation actions"]'), - "Unable to find implementation actions trigger.", - ); - - await vi.waitFor( - () => { - const implementRect = implementButton.getBoundingClientRect(); - const implementActionsRect = implementActionsButton.getBoundingClientRect(); - const compactModelPicker = findComposerProviderModelPicker(); - expect(compactModelPicker).toBeTruthy(); - - const compactModelPickerOffset = - compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; - - expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); - expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); - expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1); - expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( - 1, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, - planMarkdown: - "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", - }), - }); - - try { - await waitForButtonByText("Implement"); - - await vi.waitFor( - () => { - const footer = document.querySelector('[data-chat-composer-footer="true"]'); - const actions = document.querySelector( - '[data-chat-composer-actions="right"]', - ); - - expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); - expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("compacts the footer when a wide desktop follow-up layout starts overflowing", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex-spark", - }, - planMarkdown: - "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", - }), - }); - - try { - await waitForButtonByText("Implement"); - - await mounted.setContainerSize({ - width: 804, - height: WIDE_FOOTER_VIEWPORT.height, - }); - - await expectComposerActionsContained(); - - await vi.waitFor( - () => { - const footer = document.querySelector('[data-chat-composer-footer="true"]'); - const actions = document.querySelector( - '[data-chat-composer-actions="right"]', - ); - - expect(footer?.dataset.chatComposerFooterCompact).toBe("true"); - expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the slash-command menu visible above the composer", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-menu-target" as MessageId, - targetText: "command menu thread", - }), - }); - - try { - await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("/"); - - const menuItem = await waitForComposerMenuItem("slash:model"); - const composerForm = await waitForElement( - () => document.querySelector('[data-chat-composer-form="true"]'), - "Unable to find composer form.", - ); - - await vi.waitFor( - () => { - const menuRect = menuItem.getBoundingClientRect(); - const composerRect = composerForm.getBoundingClientRect(); - const hitTarget = document.elementFromPoint( - menuRect.left + menuRect.width / 2, - menuRect.top + menuRect.height / 2, - ); - - expect(menuRect.width).toBeGreaterThan(0); - expect(menuRect.height).toBeGreaterThan(0); - expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); - expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("opens the model picker when selecting /model", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-command-target" as MessageId, - targetText: "model command thread", - }), - }); - - try { - await waitForComposerEditor(); - await page.getByTestId("composer-editor").fill("/mod"); - - const menuItem = await waitForComposerMenuItem("slash:model"); - await menuItem.click(); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); - }); - - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - - await vi.waitFor(() => { - const searchInput = document.querySelector( - 'input[placeholder="Search models..."]', - ); - expect(searchInput).not.toBeNull(); - expect(document.activeElement).toBe(searchInput); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, - targetText: "model picker shortcut thread", - }); - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: { - ...snapshot, - projects: snapshot.projects.map((project) => - project.id === PROJECT_ID - ? Object.assign({}, project, { - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - }) - : project, - ), - threads: snapshot.threads.map((thread) => - thread.id === THREAD_ID - ? Object.assign({}, thread, { - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - }) - : thread, - ), - }, - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "modelPicker.toggle", - shortcut: { - key: "m", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "thread.jump.1", - shortcut: { - key: "1", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }, - }, - { - command: "modelPicker.jump.1", - shortcut: { - key: "1", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }, - whenAst: { type: "identifier", name: "modelPickerOpen" }, - }, - ], - providers: [ - { - ...nextFixture.serverConfig.providers[0]!, - models: [ - { - slug: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, - ], - }), - }, - ], - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - await waitForComposerEditor(); - - const initialPath = mounted.router.state.location.pathname; - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).not.toBeNull(); - }); - - const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; - await vi.waitFor(() => { - expect( - Array.from( - document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), - ).some((element) => element.textContent?.trim() === jumpLabel), - ).toBe(true); - }); - expect(mounted.router.state.location.pathname).toBe(initialPath); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "m", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - - await vi.waitFor(() => { - expect(document.querySelector(".model-picker-list")).toBeNull(); - }); - } finally { - releaseModShortcut("Control"); - await mounted.cleanup(); - } - }); - - it("shows a tooltip with the skill description when hovering a skill pill", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-skill-tooltip-target" as MessageId, - targetText: "skill tooltip thread", - }), - configureFixture: (nextFixture) => { - const provider = nextFixture.serverConfig.providers[0]; - if (!provider) { - throw new Error("Expected default provider in test fixture."); - } - ( - provider as { - skills: ServerConfig["providers"][number]["skills"]; - } - ).skills = [ - { - name: "agent-browser", - displayName: "Agent Browser", - description: "Open pages, click around, and inspect web apps.", - path: "/Users/test/.agents/skills/agent-browser/SKILL.md", - enabled: true, - }, - ]; - }, - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); - await waitForComposerText("use the $agent-browser "); - - await waitForElement( - () => document.querySelector('[data-composer-skill-chip="true"]'), - "Unable to find rendered composer skill chip.", - ); - await page.getByText("Agent Browser").hover(); - - await vi.waitFor( - () => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain("Open pages, click around, and inspect web apps."); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bbb59fd6bb8..43ed895c0db 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,16 +1,7 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { type EnvironmentState, useStore } from "../store"; -import { type Thread } from "../types"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -23,10 +14,60 @@ import { reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, - waitForStartedServerThread, } from "./ChatView.logic"; -const localEnvironmentId = EnvironmentId.make("environment-local"); +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); +const threadId = ThreadId.make("thread-1"); +const now = "2026-03-29T00:00:00.000Z"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: threadId, + environmentId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + ...overrides, + }; +} + +const completedTurn = { + turnId: TurnId.make("turn-1"), + state: "completed" as const, + requestedAt: now, + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: "2026-03-29T00:00:10.000Z", + assistantMessageId: null, +}; + +const readySession = { + threadId, + status: "ready" as const, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-29T00:00:10.000Z", +}; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -36,13 +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, }, ], }); @@ -60,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, }, ], }); @@ -102,14 +143,11 @@ describe("deriveComposerSendState", () => { }); describe("buildExpiredTerminalContextToastCopy", () => { - it("formats clear empty-state guidance", () => { + it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ title: "Expired terminal context won't be sent", description: "Remove it or re-add it to include terminal output.", }); - }); - - it("formats omission guidance for sent messages", () => { expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ title: "Expired terminal contexts omitted from message", description: "Re-add it if you want that terminal output included.", @@ -185,94 +223,38 @@ describe("getStartedThreadModelChangeBlockReason", () => { }); describe("resolveSendEnvMode", () => { - it("keeps worktree mode for git repositories", () => { + it("keeps worktree mode only for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - }); - - it("forces local mode for non-git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); }); }); describe("reconcileMountedTerminalThreadIds", () => { - it("keeps previously mounted open threads and adds the active open thread", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")], - openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")], - activeThreadId: ThreadId.make("thread-active"), - activeThreadTerminalOpen: true, - }), - ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); - }); - - it("drops mounted threads once their terminal drawer is no longer open", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-closed")], - openThreadIds: [], - activeThreadId: ThreadId.make("thread-closed"), - activeThreadTerminalOpen: false, - }), - ).toEqual([]); - }); - - it("keeps only the most recently active hidden terminal threads", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ], - openThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ], - activeThreadId: ThreadId.make("thread-4"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); - }); - - it("moves the active thread to the end so it is treated as most recently used", () => { + it("keeps open threads and makes the active thread most recent", () => { expect( reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - openThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - activeThreadId: ThreadId.make("thread-a"), + currentThreadIds: ["thread-a", "thread-b", "thread-c"], + openThreadIds: ["thread-a", "thread-b", "thread-c"], + activeThreadId: "thread-a", activeThreadTerminalOpen: true, maxHiddenThreadCount: 2, }), - ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); + ).toEqual(["thread-b", "thread-c", "thread-a"]); }); - it("defaults to the hidden mounted terminal cap", () => { - const currentThreadIds = Array.from( + it("drops closed threads and enforces the hidden mounted cap", () => { + const ids = Array.from( { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => ThreadId.make(`thread-${index + 1}`), + (_, index) => `thread-${index}`, ); - expect( reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: currentThreadIds, + currentThreadIds: ids, + openThreadIds: ids.slice(1), activeThreadId: null, activeThreadTerminalOpen: false, }), - ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); }); }); @@ -321,319 +303,38 @@ describe("reconcileRetainedMountedThreadIds", () => { }); describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("routes errors to the active server thread when route and target match", () => { - const threadId = ThreadId.make("thread-1"); - const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + it("requires the environment, route thread, and target thread to match", () => { + const routeThreadRef = { environmentId, threadId }; expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { - environmentId: localEnvironmentId, - id: threadId, - }, + serverThread: { environmentId, id: threadId }, routeThreadRef, targetThreadId: threadId, }), ).toBe(true); - }); - - it("does not route draft-thread errors into server-backed state", () => { - const threadId = ThreadId.make("thread-1"); - expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: undefined, - routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + serverThread: null, + routeThreadRef, targetThreadId: threadId, }), ).toBe(false); }); }); -const makeThread = (input?: { - id?: ThreadId; - latestTurn?: { - turnId: TurnId; - state: "running" | "completed"; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - } | null; -}): Thread => ({ - id: input?.id ?? ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - session: null, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:00.000Z", - latestTurn: input?.latestTurn - ? { - ...input.latestTurn, - assistantMessageId: null, - } - : null, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], -}); - -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, @@ -641,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, @@ -687,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, @@ -823,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 0012bee256b..36947caae6f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -9,10 +9,11 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; +import { type ChatMessage, type SessionPhase, type Thread } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; -import { selectThreadByRef, useStore } from "../store"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentThreadDetails } from "../state/threads"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -30,12 +31,10 @@ export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, - error: string | null, ): Thread { return { id: threadId, environmentId: draftThread.environmentId, - codexThreadId: null, projectId: draftThread.projectId, title: "New thread", modelSelection: fallbackModelSelection, @@ -43,13 +42,14 @@ export function buildLocalDraftThread( interactionMode: draftThread.interactionMode, session: null, messages: [], - error, createdAt: draftThread.createdAt, + updatedAt: draftThread.createdAt, archivedAt: null, + deletedAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], }; @@ -275,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 = @@ -332,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)) { @@ -354,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); @@ -379,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; } @@ -396,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, }; } @@ -433,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; @@ -444,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 52f25945510..cf5bb9de5e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,7 +21,16 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -31,17 +40,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 { useAtomValue } from "@effect/atom-react"; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; -import { useVcsStatus } from "~/lib/vcsStatusState"; -import { usePrimaryEnvironmentId } from "../environments/primary/context"; -import { readEnvironmentApi } from "../environmentApi"; -import { resolveAssetUrl } from "../assets/assetUrls"; +import { + isAtomCommandInterrupted, + mapAtomCommandResult, + settlePromise, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -67,8 +81,6 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { selectEnvironmentState, selectProjectsAcrossEnvironments, useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -87,12 +99,12 @@ 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 { - selectActiveRightPanelKindWithUrl, + selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, @@ -100,26 +112,21 @@ import { } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, - selectThreadPreviewState, - usePreviewStateStore, + setActivePreviewTab, + useThreadPreviewState, } from "../previewStateStore"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; -// Lazy: keeps the entire preview component graph (webview host, favicon -// helper, Chromium error icon) out of the web bundle until first open. -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), -); -const DiffPanel = lazy(() => import("./DiffPanel")); -const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); -const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; -import { RightPanelTabs } from "./RightPanelTabs"; -import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -129,20 +136,16 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { useSettings } from "../hooks/useSettings"; +import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime/catalog"; -import { reconnectSavedEnvironment } from "../environments/runtime/service"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -156,8 +159,6 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; import { appendElementContextsToPrompt, type ElementContextDraft, @@ -165,6 +166,28 @@ import { } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; +import { environmentCatalog } from "../connection/catalog"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { + primaryServerAvailableEditorsAtom, + primaryServerKeybindingsAtom, + serverEnvironment, +} from "../state/server"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + useProject, + useProjects, + useThread, + useThreadProposedPlans, + useThreadRefs, +} from "../state/entities"; +import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -173,11 +196,12 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -192,22 +216,18 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, + reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - useServerAvailableEditors, - useServerConfig, - useServerKeybindings, -} from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; +import { previewEnvironment } from "../state/preview"; +import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; import { buildVersionMismatchDismissalKey, @@ -215,14 +235,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", @@ -255,7 +281,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; }; type ThreadPlanCatalogEntry = Pick; @@ -280,119 +306,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } -function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - return useStore( - useMemo(() => { - let previousThreadIds: readonly ThreadId[] = []; - let previousResult: ThreadPlanCatalogEntry[] = []; - let previousEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - - return (state) => { - const sameThreadIds = - previousThreadIds.length === threadIds.length && - previousThreadIds.every((id, index) => id === threadIds[index]); - const nextEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - const nextResult: ThreadPlanCatalogEntry[] = []; - let changed = !sameThreadIds; - - for (const threadId of threadIds) { - let shell: object | undefined; - let proposedPlanIds: readonly string[] | undefined; - let proposedPlansById: Record | undefined; - - for (const environmentState of Object.values(state.environmentStateById)) { - const matchedShell = environmentState.threadShellById[threadId]; - if (!matchedShell) { - continue; - } - shell = matchedShell; - proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; - proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as - | Record - | undefined; - break; - } - - if (!shell) { - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === null && - previous.proposedPlanIds === undefined && - previous.proposedPlansById === undefined - ) { - nextEntries.set(threadId, previous); - continue; - } - changed = true; - nextEntries.set(threadId, { - shell: null, - proposedPlanIds: undefined, - proposedPlansById: undefined, - entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, - }); - continue; - } - - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === shell && - previous.proposedPlanIds === proposedPlanIds && - previous.proposedPlansById === proposedPlansById - ) { - nextEntries.set(threadId, previous); - nextResult.push(previous.entry); - continue; - } - - changed = true; - const proposedPlans = - proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById - ? proposedPlanIds.flatMap((planId) => { - const proposedPlan = proposedPlansById?.[planId]; - return proposedPlan ? [proposedPlan] : []; - }) - : EMPTY_PROPOSED_PLANS; - const entry = { id: threadId, proposedPlans }; - nextEntries.set(threadId, { - shell, - proposedPlanIds, - proposedPlansById, - entry, - }); - nextResult.push(entry); - } - - if (!changed && previousResult.length === nextResult.length) { - return previousResult; - } - - previousThreadIds = threadIds; - previousEntries = nextEntries; - previousResult = nextResult; - return nextResult; - }; - }, [threadIds]), - ); -} - function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -443,21 +356,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); }, []); @@ -483,20 +381,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, }; } @@ -543,7 +450,6 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; - mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; @@ -558,7 +464,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, - mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, @@ -568,14 +473,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const project = useProject(projectRef); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); @@ -634,7 +542,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: worktreePathForLaunch, }), }); @@ -680,7 +588,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), @@ -690,7 +598,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : {}, @@ -712,26 +620,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); - void (async () => { - try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. - } - })(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); }, [ bumpFocusRequestId, cwd, @@ -741,28 +645,30 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeSplitTerminal, threadId, threadRef, + openTerminal, ]); const splitTerminalVertical = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); - void api.terminal - .open({ + void openTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, - }) - .catch(() => undefined); + }, + }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, + openTerminal, runtimeEnv, serverOrderedTerminalIds, storeSplitTerminalVertical, @@ -771,26 +677,22 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); - void (async () => { - try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. - } - })(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); }, [ bumpFocusRequestId, cwd, @@ -800,6 +702,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeNewTerminal, threadId, threadRef, + openTerminal, ]); const activateTerminal = useCallback( @@ -812,31 +715,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( @@ -854,9 +763,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; @@ -924,41 +832,41 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const serverThread = useThread(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const project = useProject(projectRef); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId: threadRef.threadId, }); - const terminalSummary = + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const worktreePath = - launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; + launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; const cwd = useMemo( () => launchContext?.cwd ?? - terminalSummary?.cwd ?? + activeSummary?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath, }) : null), - [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath, }) : {}, @@ -994,7 +902,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane summary?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }) : null); @@ -1003,7 +911,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane cwd: terminalCwd, worktreePath: terminalWorktreePath, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }), }); @@ -1018,9 +926,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane threadWorktreePath, ]); - if (!project || !cwd) { - return null; - } + if (!project || !cwd) return null; return ( scopedThreadKey(routeThreadRef), [routeThreadRef]); + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { + reportFailure: false, + }); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); + const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const 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( @@ -1156,6 +1092,9 @@ function ChatViewContent(props: ChatViewProps) { const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); + const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< + Record + >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( @@ -1187,6 +1126,10 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); + const [ + pendingServerThreadStartFromOriginByThreadId, + setPendingServerThreadStartFromOriginByThreadId, + ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -1202,23 +1145,50 @@ function ChatViewContent(props: ChatViewProps) { const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); + const openTerminalThreadKeys = useTerminalUiStateStore( + useShallow((state) => + Object.entries(state.terminalUiStateByThreadKey).flatMap( + ([nextThreadKey, nextTerminalUiState]) => + nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], + ), + ), + ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); + const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); + const serverThreadRefs = useThreadRefs(); + const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], + ); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const mountedTerminalThreadRefs = useMemo( + () => + mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedTerminalThreadKeys], + ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const fallbackDraftProject = useStore( - useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), - ); + const fallbackDraftProject = useProject(fallbackDraftProjectRef); const localDraftError = routeKind === "server" && serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; const localDraftThread = useMemo( () => draftThread @@ -1229,19 +1199,20 @@ function ChatViewContent(props: ChatViewProps) { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], + [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== undefined; + const isServerThread = routeKind === "server" && serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const threadError = isServerThread + ? (localServerError ?? serverThread?.session?.lastError ?? null) + : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, @@ -1268,35 +1239,33 @@ function ChatViewContent(props: ChatViewProps) { [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); const activeTerminalLabelsById = useMemo(() => { - const next = new Map(); + const labels = new Map(); for (const session of activeThreadKnownSessions) { - next.set( + labels.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } - return next; + return labels; }, [activeThreadKnownSessions]); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const activeRightPanelKind = useRightPanelStore((store) => - selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), + const activeRightPanelKind = useRightPanelStore((state) => + selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); - const rightPanelState = useRightPanelStore((store) => - selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + const diffOpen = activeRightPanelKind === "diff"; + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); - const activeRightPanelSurface = useRightPanelStore((store) => - selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + const activeRightPanelSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; - const activePreviewState = usePreviewStateStore((state) => - selectThreadPreviewState(state.byThreadKey, activeThreadRef), - ); + const activePreviewState = useThreadPreviewState(activeThreadRef); const panelTerminalIds = useMemo( () => new Set( @@ -1306,37 +1275,11 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); - const drawerServerOrderedTerminalIds = useMemo( - () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), - [activeServerOrderedTerminalIds, panelTerminalIds], - ); - useEffect(() => { - if (!activeThreadRef) { - return; - } - if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient( - drawerServerOrderedTerminalIds, - terminalUiState.terminalIds, - ) - ) { - return; - } - reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); - }, [ - activeThreadRef, - drawerServerOrderedTerminalIds, - reconcileTerminalIds, - terminalUiState.terminalIds, - ]); - const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = - rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey; + canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { @@ -1346,33 +1289,62 @@ function ChatViewContent(props: ChatViewProps) { .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); - useEffect(() => { - if (!activeThreadRef || !diffOpen) return; - useRightPanelStore.getState().open(activeThreadRef, "diff"); - }, [activeThreadRef, diffOpen]); + const planSidebarOpen = activeRightPanelKind === "plan"; + + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const threadPlanCatalog = useThreadPlanCatalog( - useMemo(() => { - const threadIds: ThreadId[] = []; - if (activeThread?.id) { - threadIds.push(activeThread.id); - } - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (sourceThreadId && sourceThreadId !== activeThread?.id) { - threadIds.push(sourceThreadId); - } - return threadIds; - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), - ); + const sourcePlanThreadRef = useMemo(() => { + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { + return null; + } + return scopeThreadRef(activeThread.environmentId, sourceThreadId); + }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); + const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); + const threadPlanCatalog = useMemo(() => { + if (!activeThread) { + return []; + } + const entries: ThreadPlanCatalogEntry[] = [ + { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, + ]; + if (sourcePlanThreadRef) { + entries.push({ + id: sourcePlanThreadRef.threadId, + proposedPlans: sourceThreadProposedPlans, + }); + } + return entries; + }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); + useEffect(() => { + setMountedTerminalThreadKeys((currentThreadIds) => { + const nextThreadIds = reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; - const activeProject = useStore( - useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), + const activeProject = useProject(activeProjectRef); + const activeEnvironmentShell = useEnvironmentQuery( + activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); + const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some"; const activeProjectKey = activeProject - ? `${activeProject.environmentId}:${activeProject.cwd}` + ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> @@ -1387,30 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; const surfaceId = `file:${relativePath}`; if (current.has(surfaceId) === pending) return currentByProject; - const next = new Set(current); - if (pending) { - next.add(surfaceId); - } else { - next.delete(surfaceId); - } - + if (pending) next.add(surfaceId); + else next.delete(surfaceId); const nextByProject = new Map(currentByProject); - if (next.size === 0) { - nextByProject.delete(activeProjectKey); - } else { - nextByProject.set(activeProjectKey, next); - } + if (next.size === 0) nextByProject.delete(activeProjectKey); + else nextByProject.set(activeProjectKey, next); return nextByProject; }); }, [activeProjectKey], ); - const activeEnvironmentBootstrapComplete = useStore((state) => - activeThread - ? selectEnvironmentState(state, activeThread.environmentId).bootstrapComplete - : false, - ); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProject?.scripts), [activeProject?.scripts], @@ -1418,83 +1377,35 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore - .getState() - .reconcileFileSurfaces(activeThreadRef, activeProject !== undefined); + useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); - useEffect(() => { - if (routeKind !== "server") { - return; - } - return retainThreadDetailSubscription(environmentId, threadId); - }, [environmentId, routeKind, threadId]); - // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. - const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const activeSavedEnvironmentRecord = - activeThread && activeThread.environmentId !== primaryEnvironmentId - ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) - : null; - const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord - ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) - : null; - const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord - ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") - : "connected"; + const allProjects = useProjects(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironment = + activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); + const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; - const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; - const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord - ? resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: activeSavedEnvironmentRecord.environmentId, - runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, - savedLabel: activeSavedEnvironmentRecord.label, - }) - : null; + activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { - if ( - !activeEnvironmentUnavailable || - !activeEnvironmentUnavailableLabel || - !activeSavedEnvironmentId - ) { + if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { return null; } return { - environmentId: activeSavedEnvironmentId, + environmentId: activeEnvironment.environmentId, label: activeEnvironmentUnavailableLabel, - connectionState: - activeSavedEnvironmentConnectionState === "connecting" || - activeSavedEnvironmentConnectionState === "error" - ? activeSavedEnvironmentConnectionState - : "disconnected", + connection: activeEnvironment.connection, }; - }, [ - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - activeSavedEnvironmentConnectionState, - activeSavedEnvironmentId, - ]); - const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( - null, - ); + }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); const handleReconnectActiveEnvironment = useCallback( - async (environmentId: EnvironmentId, label: string) => { - setReconnectingEnvironmentId(environmentId); - try { - await reconnectSavedEnvironment(environmentId); - toastManager.add({ - type: "success", - title: "Environment reconnected", - description: `${label} is ready.`, - }); - } catch (error) { + async (environmentId: EnvironmentId) => { + const result = await retryEnvironment(environmentId); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1502,13 +1413,11 @@ function ChatViewContent(props: ChatViewProps) { description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); - } finally { - setReconnectingEnvironmentId(null); } }, - [], + [retryEnvironment], ); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1526,14 +1435,7 @@ function ChatViewContent(props: ChatViewProps) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[p.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; - const label = resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: p.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: savedRecord?.label ?? null, - }); + const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; envs.push({ environmentId: p.environmentId, projectId: p.id, @@ -1547,14 +1449,7 @@ function ChatViewContent(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [ - activeProject, - allProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( @@ -1664,24 +1559,21 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!serverThread?.id) return; - if (!latestTurnSettled) return; - if (!activeLatestTurn?.completedAt) return; - const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); - if (Number.isNaN(turnCompletedAt)) return; + const threadUpdatedAt = Date.parse(serverThread.updatedAt); + if (Number.isNaN(threadUpdatedAt)) return; const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - activeLatestTurn.completedAt, + serverThread.updatedAt, ); }, [ - activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - latestTurnSettled, markThreadVisited, serverThread?.environmentId, serverThread?.id, + serverThread?.updatedAt, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; @@ -1694,17 +1586,11 @@ function ChatViewContent(props: ChatViewProps) { selectedProvider: selectedProviderByThreadId, threadProvider, }); - const primaryServerConfig = useServerConfig(); - const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => - activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, - ); - // Use the server config for the thread's environment. For the primary - // environment fall back to the global atom; for remote environments use - // the runtime state stored by the environment manager. - const serverConfig = - primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId - ? primaryServerConfig - : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1718,65 +1604,37 @@ function ChatViewContent(props: ChatViewProps) { isVersionMismatchDismissed(versionMismatchDismissKey); const showVersionMismatchBanner = versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; - const versionMismatchServerLabel = useMemo(() => { - if (!hasMultipleRegisteredEnvironments || !activeThread) { - return "server"; - } - - const isPrimary = activeThread.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; - return `${resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: activeThread.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, - savedLabel: savedRecord?.label ?? null, - })} server`; - }, [ - activeThread, - hasMultipleRegisteredEnvironments, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - serverConfig?.environment.label, - ]); + const hasMultipleRegisteredEnvironments = environments.length > 1; + const versionMismatchServerLabel = + hasMultipleRegisteredEnvironments && activeThread + ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` + : "server"; const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { + const connection = activeEnvironmentUnavailableState.connection; + const isReconnecting = + connection.phase === "connecting" || connection.phase === "reconnecting"; items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: - activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", + variant: connection.phase === "error" ? "error" : "warning", icon: , - title: ( - <> - {activeEnvironmentUnavailableState.label} is{" "} - {activeEnvironmentUnavailableState.connectionState === "connecting" - ? "connecting" - : "disconnected"} - - ), - description: "Reconnect this environment before sending messages or running actions.", + title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`, + description: + connection.error ?? + "Reconnect this environment before sending messages or running actions.", actions: ( <>
{!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( @@ -5051,12 +4938,11 @@ function ChatViewContent(props: ChatViewProps) { onAddFiles={addFilesSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} - filesAvailable={Boolean(activeProject)} + filesAvailable={activeProject !== null} > {rightPanelContent} ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( {rightPanelContent} @@ -5087,7 +4973,11 @@ function ChatViewContent(props: ChatViewProps) { ) : null} {expandedImage && ( - + )}
); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index eb5fec9a91b..651fe34e4b4 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 982950be5e5..ab53adbefb1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -100,9 +100,9 @@ export function buildProjectActionItems(input: { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.name, project.cwd], - title: project.name, - description: project.cwd, + searchTerms: [project.title, project.workspaceRoot], + title: project.title, + description: project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { @@ -115,7 +115,7 @@ export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" > & { - updatedAt?: string | undefined; + updatedAt: string; latestUserMessageAt?: string | null; }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ebfc260ca43..a11d6c4cb07 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,11 @@ "use client"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_MODEL, type EnvironmentId, @@ -11,7 +16,6 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { @@ -32,26 +36,25 @@ import { useEffect, useLayoutEffect, useMemo, + useReducer, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useCommandPaletteStore } from "../commandPaletteStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; -import { - getSourceControlDiscoverySnapshot, - refreshSourceControlDiscovery, -} from "../lib/sourceControlDiscoveryState"; +import { filesystemEnvironment } from "../state/filesystem"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -73,12 +76,7 @@ import { } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { @@ -102,7 +100,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { useServerKeybindings } from "../rpc/serverState"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -120,7 +118,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -const BROWSE_STALE_TIME_MS = 30_000; function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { @@ -326,11 +323,50 @@ function errorMessage(error: unknown): string { return "An error occurred."; } +interface CommandPaletteOpenIntent { + readonly kind: "add-project"; +} + +interface CommandPaletteUiState { + readonly open: boolean; + readonly openIntent: CommandPaletteOpenIntent | null; +} + +type CommandPaletteUiAction = + | { readonly _tag: "SetOpen"; readonly open: boolean } + | { readonly _tag: "Toggle" } + | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "ClearOpenIntent" }; + +function reduceCommandPaletteUiState( + state: CommandPaletteUiState, + action: CommandPaletteUiAction, +): CommandPaletteUiState { + switch (action._tag) { + case "SetOpen": + return { + open: action.open, + openIntent: action.open ? state.openIntent : null, + }; + case "Toggle": + return { open: !state.open, openIntent: null }; + case "OpenAddProject": + return { open: true, openIntent: { kind: "add-project" } }; + case "ClearOpenIntent": + return state.openIntent ? { ...state, openIntent: null } : state; + } +} + export function CommandPalette({ children }: { children: ReactNode }) { - const open = useCommandPaletteStore((store) => store.open); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); - const keybindings = useServerKeybindings(); + const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { + open: false, + openIntent: null, + }); + const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); + const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); + const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -364,49 +400,70 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleOpen]); return ( - - - {children} - - - + + + + {children} + + + + ); } -function CommandPaletteDialog() { - const open = useCommandPaletteStore((store) => store.open); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - - useEffect(() => { - return () => { - setOpen(false); - }; - }, [setOpen]); - - if (!open) { +function CommandPaletteDialog(props: { + readonly open: boolean; + readonly openIntent: CommandPaletteOpenIntent | null; + readonly setOpen: (open: boolean) => void; + readonly clearOpenIntent: () => void; +}) { + if (!props.open) { return null; } - return ; + return ( + + ); } -function OpenCommandPaletteDialog() { +function OpenCommandPaletteDialog(props: { + readonly openIntent: CommandPaletteOpenIntent | null; + readonly setOpen: (open: boolean) => void; + readonly clearOpenIntent: () => void; +}) { const navigate = useNavigate(); - const setOpen = useCommandPaletteStore((store) => store.setOpen); - const openIntent = useCommandPaletteStore((store) => store.openIntent); - const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); + const { clearOpenIntent, openIntent, setOpen } = props; const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); - const queryClient = useQueryClient(); const [highlightedItemValue, setHighlightedItemValue] = useState(null); - const settings = useSettings(); + const clientSettings = useClientSettings(); + const createProject = useAtomCommand(projectEnvironment.create, { + reportFailure: false, + }); + const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { + reportFailure: false, + }); + const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { + reportFailure: false, + }); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const keybindings = useServerKeybindings(); + const projects = useProjects(); + const threads = useThreadShells(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -417,45 +474,21 @@ function OpenCommandPaletteDialog() { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const addProjectEnvironmentOptions = useMemo(() => { - const options: AddProjectEnvironmentOption[] = []; - const seenEnvironmentIds = new Set(); - - if (primaryEnvironmentId) { - seenEnvironmentIds.add(primaryEnvironmentId); - options.push({ - environmentId: primaryEnvironmentId, - label: resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: primaryEnvironmentId, - runtimeLabel: primaryEnvironmentLabel, - }), - isPrimary: true, - }); - } - - for (const record of Object.values(savedEnvironmentRegistry)) { - if (seenEnvironmentIds.has(record.environmentId)) { - continue; - } - - const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; - options.push({ - environmentId: record.environmentId, + const options = environments.map((environment): AddProjectEnvironmentOption => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return { + environmentId: environment.environmentId, label: resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: record.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: record.label, + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, }), - isPrimary: false, - }); - } + isPrimary, + }; + }); options.sort((left, right) => { if (left.isPrimary !== right.isPrimary) { @@ -465,26 +498,22 @@ function OpenCommandPaletteDialog() { }); return options; - }, [ - primaryEnvironmentId, - primaryEnvironmentLabel, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environments]); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; - const browseEnvironmentPlatform = useMemo(() => { - const os = - browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId - ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) - : browseEnvironmentId - ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? - savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform - .os ?? - null) - : null; - return getEnvironmentBrowsePlatform(os); - }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const browseEnvironment = + environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; + const sourceControlDiscovery = useEnvironmentQuery( + browseEnvironmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: browseEnvironmentId, + input: {}, + }), + ); + const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( + browseEnvironment?.serverConfig?.environment.platform.os, + ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; const isBrowsing = @@ -492,27 +521,26 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { - const environmentSettings = - environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId - ? settings - : environmentId - ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings - : null; + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + const environmentSettings = environment?.serverConfig?.settings ?? null; const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + [environments], ); const projectCwdById = useMemo( - () => new Map(projects.map((project) => [project.id, project.cwd])), + () => + new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.name])), + () => new Map(projects.map((project) => [project.id, project.title])), [projects], ); @@ -532,75 +560,34 @@ function OpenCommandPaletteDialog() { const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const browseFilterQuery = isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; - - const fetchBrowseResult = useCallback( - async (partialPath: string): Promise => { - if (!browseEnvironmentId) return null; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return null; - return api.filesystem.browse({ - partialPath, - ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse], - ); - - const { data: browseResult, isPending: isBrowsePending } = useQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - browseDirectoryPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(browseDirectoryPath), - staleTime: BROWSE_STALE_TIME_MS, - enabled: - isBrowsing && + const browseQuery = useEnvironmentQuery( + isBrowsing && browseDirectoryPath.length > 0 && browseEnvironmentId !== null && - !relativePathNeedsActiveProject, - }); + !relativePathNeedsActiveProject + ? filesystemEnvironment.browse({ + environmentId: browseEnvironmentId, + input: { + partialPath: browseDirectoryPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }, + }) + : null, + ); + const browseResult = browseQuery.data; + const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); - const prefetchBrowsePath = useCallback( - (partialPath: string) => { - void queryClient.prefetchQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - partialPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(partialPath), - staleTime: BROWSE_STALE_TIME_MS, - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], - ); - - // Prefetch only the parent (for back-navigation). Prefetching the - // highlighted child on every arrow-key press triggers a macOS TCC prompt - // whenever the highlighted entry is a permission-gated home dir (Music, - // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. - useEffect(() => { - if (!isBrowsing || filteredBrowseEntries.length === 0) return; - - if (canNavigateUp(query)) { - prefetchBrowsePath(getBrowseParentPath(query)!); - } - }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); - const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === project.environmentId), project.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -612,17 +599,9 @@ function OpenCommandPaletteDialog() { return; } - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - envMode: settings.defaultThreadEnvMode, - }); + await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }, - [ - handleNewThread, - navigate, - settings.defaultThreadEnvMode, - settings.sidebarThreadSortOrder, - threads, - ], + [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads], ); const projectSearchItems = useMemo( @@ -633,7 +612,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -651,7 +630,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -659,23 +638,15 @@ function OpenCommandPaletteDialog() { await startNewThreadInProjectFromContext( { activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [ - activeDraftThread, - activeThread, - defaultProjectRef, - handleNewThread, - projects, - settings.defaultThreadEnvMode, - ], + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], ); const allThreadItems = useMemo( @@ -684,7 +655,7 @@ function OpenCommandPaletteDialog() { threads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, - sortOrder: settings.sidebarThreadSortOrder, + sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , @@ -695,7 +666,7 @@ function OpenCommandPaletteDialog() { }); }, }), - [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -867,40 +838,17 @@ function OpenCommandPaletteDialog() { (environmentId: EnvironmentId): void => { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); - const target = { environmentId }; - const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness(initialDiscovery), + buildAddProjectRemoteSourceReadiness( + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, + ), ), }); - - if (initialDiscovery) { - return; - } - - void refreshSourceControlDiscovery(target).then((discovery) => { - setViewStack((previousViews) => { - const currentTopView = previousViews.at(-1); - if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { - return previousViews; - } - return [ - ...previousViews.slice(0, -1), - { - addonIcon: , - groups: buildAddProjectSourceGroups( - environmentId, - buildAddProjectRemoteSourceReadiness(discovery), - ), - }, - ]; - }); - }); }, - [buildAddProjectSourceGroups], + [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -988,9 +936,8 @@ function OpenCommandPaletteDialog() { run: async () => { await startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, }); }, @@ -1049,7 +996,17 @@ function OpenCommandPaletteDialog() { }); const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); - const activeGroups = currentView ? currentView.groups : rootGroups; + const sourceSelectionViewValue = + addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; + const activeGroups = + addProjectEnvironmentId !== null && + currentView !== null && + currentView.groups[0]?.value === sourceSelectionViewValue + ? buildAddProjectSourceGroups( + addProjectEnvironmentId, + buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), + ) + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, @@ -1062,8 +1019,6 @@ function OpenCommandPaletteDialog() { const handleAddProject = useCallback( async (rawCwd: string) => { if (!browseEnvironmentId) return; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { toastManager.add( @@ -1098,7 +1053,7 @@ function OpenCommandPaletteDialog() { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -1108,19 +1063,29 @@ function OpenCommandPaletteDialog() { ), }); } else { - await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { - envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); + const navigationResult = await settlePromise(() => + handleNewThread(scopeProjectRef(existing.environmentId, existing.id)), + ); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } setOpen(false); return; } - try { - const projectId = newProjectId(); - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), + const projectId = newProjectId(); + const createResult = await createProject({ + environmentId: browseEnvironmentId, + input: { projectId, title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, @@ -1129,13 +1094,27 @@ function OpenCommandPaletteDialog() { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - createdAt: new Date().toISOString(), - }); - await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { - envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); - setOpen(false); - } catch (error) { + }, + }); + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + + const navigationResult = await settlePromise(() => + handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), + ); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); toastManager.add( stackedThreadToast({ type: "error", @@ -1143,18 +1122,20 @@ function OpenCommandPaletteDialog() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + setOpen(false); }, [ browseEnvironmentId, browseEnvironmentPlatform, currentProjectCwdForBrowse, handleNewThread, + createProject, navigate, projects, setOpen, - settings.defaultThreadEnvMode, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, threads, ], ); @@ -1168,18 +1149,6 @@ function OpenCommandPaletteDialog() { return; } - const api = readEnvironmentApi(addProjectCloneFlow.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to clone project", - description: "Environment API is not available.", - }), - ); - return; - } - if (addProjectCloneFlow.step === "repository") { const rawRepository = query.trim(); if (rawRepository.length === 0 || isRemoteProjectLookingUp) { @@ -1204,34 +1173,39 @@ function OpenCommandPaletteDialog() { } setIsRemoteProjectLookingUp(true); - try { - const repository = await api.sourceControl.lookupRepository({ + const lookupResult = await lookupRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { provider, repository: rawRepository, - }); - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); - setAddProjectCloneFlow({ - step: "confirm", - environmentId: addProjectCloneFlow.environmentId, - source: addProjectCloneFlow.source, - repositoryInput: rawRepository, - repository, - remoteUrl: repository.sshUrl, - }); - setHighlightedItemValue(null); - setQuery(destinationPath); - setBrowseGeneration((generation) => generation + 1); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Repository lookup failed", - description: errorMessage(error), - }), - ); - } finally { - setIsRemoteProjectLookingUp(false); + }, + }); + setIsRemoteProjectLookingUp(false); + if (lookupResult._tag === "Failure") { + if (!isAtomCommandInterrupted(lookupResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository lookup failed", + description: errorMessage(squashAtomCommandFailure(lookupResult)), + }), + ); + } + return; } + const repository = lookupResult.value; + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + remoteUrl: repository.sshUrl, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); return; } @@ -1271,23 +1245,27 @@ function OpenCommandPaletteDialog() { } setIsRemoteProjectCloning(true); - try { - const result = await api.sourceControl.cloneRepository({ + const cloneResult = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, - }); - await handleAddProject(result.cwd); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Clone failed", - description: errorMessage(error), - }), - ); - } finally { - setIsRemoteProjectCloning(false); + }, + }); + setIsRemoteProjectCloning(false); + if (cloneResult._tag === "Failure") { + if (!isAtomCommandInterrupted(cloneResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(squashAtomCommandFailure(cloneResult)), + }), + ); + } + return; } + await handleAddProject(cloneResult.value.cwd); } function browseTo(name: string): void { @@ -1515,6 +1493,7 @@ function OpenCommandPaletteDialog() { { composerHandleRef?.current?.focusAtEnd(); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 080fa291e7e..f39af581d5a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,32 +1,29 @@ -import { Virtualizer } from "@pierre/diffs/react"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { useParams } from "@tanstack/react-router"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { + ArrowRightIcon, + CheckIcon, ChevronDownIcon, - ChevronLeftIcon, ChevronRightIcon, Columns2Icon, PilcrowIcon, Rows3Icon, + SearchIcon, TextWrapIcon, } from "lucide-react"; -import { - type WheelEvent as ReactWheelEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; +import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -36,18 +33,49 @@ import { resolveFileDiffPath, } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { useSettings } from "../hooks/useSettings"; +import { useProject, useThread } from "../state/entities"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; +import { Switch } from "./ui/switch"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "./ui/combobox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { reviewEnvironment } from "../state/review"; +import { vcsEnvironment } from "../state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; + +interface CollapsedDiffFilesState { + readonly scopeKey: string | null; + readonly fileKeys: ReadonlySet; +} + +const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], @@ -155,44 +183,51 @@ interface DiffPanelProps { export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { - const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const settings = useSettings(); + const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); - const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState>( - () => new Set(), - ); - const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); + const [baseRefQuery, setBaseRefQuery] = useState(""); + const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ + scopeKey: null, + fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, + })); + const codeViewRef = useRef(null); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), + const diffSelection = useDiffPanelStore((state) => + selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const activeThreadId = routeThreadRef?.threadId ?? null; + const activeThread = useThread(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => + const activeProject = useProject( activeThread && activeProjectId - ? selectProjectByRef(store, { + ? { environmentId: activeThread.environmentId, projectId: activeProjectId, + } + : null, + ); + const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const serverConfig = useAtomValue( + serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), + ); + const openInPreferredEditor = useOpenInPreferredEditor( + activeThread?.environmentId ?? null, + serverConfig?.availableEditors ?? [], + ); + const gitStatusQuery = useEnvironmentQuery( + activeThread !== null && activeThread !== undefined && activeCwd != null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeCwd }, }) - : undefined, + : null, ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useVcsStatus({ - environmentId: activeThread?.environmentId ?? null, - cwd: activeCwd ?? null, - }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -211,8 +246,20 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + useEffect(() => { + if (!routeThreadRef || diffSelection.kind !== "turn") return; + useDiffPanelStore.getState().reconcileTurnSelection( + routeThreadRef, + orderedTurnDiffSummaries.map((summary) => summary.turnId), + ); + }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); + + const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; + const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; + const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; + const selectedFileRevealRequestId = + diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; const selectedTurn = selectedTurnId === null ? undefined @@ -221,10 +268,28 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; + const latestTurn = orderedTurnDiffSummaries[0]; + const selectedScopeLabel = + selectedTurnId === null + ? selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes" + : selectedTurn?.turnId === latestTurn?.turnId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; + const collapseScopeKey = routeThreadRef + ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` + : null; + const collapsedDiffFileKeys = + collapsedDiffFiles.scopeKey === collapseScopeKey + ? collapsedDiffFiles.fileKeys + : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : "All turns"; + : selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -235,62 +300,116 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : null, [selectedCheckpointTurnCount], ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts: Array = []; - for (const summary of orderedTurnDiffSummaries) { - const value = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof value === "number") { - turnCounts.push(value); - } - } - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiff = useCheckpointDiff( { environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, + toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, + cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null, }, - { enabled: isGitRepo }, + { enabled: isGitRepo && selectedTurn !== undefined }, ); - const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined; - const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; - const checkpointDiffError = activeCheckpointDiff.error; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const primaryBranchDiffPreview = useEnvironmentQuery( + selectedTurnId === null && activeThread && activeCwd + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { + cwd: activeCwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, + }, + }) + : null, + ); + const shouldRetryBranchDiffAtEnvironmentCwd = + selectedTurnId === null && + primaryBranchDiffPreview.error?.includes("configured workspace root") === true && + serverConfig?.cwd !== undefined && + serverConfig.cwd !== activeCwd; + const fallbackBranchDiffPreview = useEnvironmentQuery( + shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig + ? reviewEnvironment.diffPreview({ + environmentId: activeThread.environmentId, + input: { + cwd: serverConfig.cwd, + ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ignoreWhitespace: diffIgnoreWhitespace, + }, + }) + : null, + ); + const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd + ? fallbackBranchDiffPreview + : primaryBranchDiffPreview; + const selectedGitSource = branchDiffPreview.data?.sources.find( + (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + ); + const localBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "local", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const remoteBranchRefs = useEnvironmentQuery( + selectedTurnId === null && + selectedGitScope === "branch" && + activeThread && + branchDiffPreview.data?.cwd + ? vcsEnvironment.listRefs({ + environmentId: activeThread.environmentId, + input: { + cwd: branchDiffPreview.data.cwd, + includeMatchingRemoteRefs: true, + refKind: "remote", + ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), + limit: 100, + }, + }) + : null, + ); + const baseRefChoices = buildBaseRefChoices( + localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], + remoteBranchRefs.data?.refs ?? [], + ); + const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery); + const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) => + selectedBaseRef && selectedBaseRef === choice.remote?.name + ? selectedBaseRef + : (choice.local?.name ?? choice.remote?.name ?? choice.id); + const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)]; + const filteredBaseRefItems = [ + ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []), + ...matchingBaseRefChoices.map(valueForBaseRefChoice), + ]; + const gitDiff = selectedGitSource?.diff; + + const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; + const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; + const isLoadingSelectedPatch = selectedTurn + ? activeCheckpointDiff.isPending + : branchDiffPreview.isPending; + const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( - () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), - [resolvedTheme, selectedPatch], + () => + getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, { + compactPartialHunkOffsets: selectedTurnId === null, + }), + [resolvedTheme, selectedPatch, selectedTurnId], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -303,37 +422,26 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff }), ); }, [renderablePatch]); + const codeViewFiles = useMemo( + () => + renderableFiles.map((fileDiff) => { + const fileKey = buildFileDiffRenderKey(fileDiff); + return { + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey, + collapsed: collapsedDiffFileKeys.has(fileKey), + }; + }), + [collapsedDiffFileKeys, renderableFiles], + ); useEffect(() => { - if (renderableFiles.length === 0) { - setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set())); - return; - } - - const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey)); - setCollapsedDiffFileKeys((current) => { - const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey))); - return next.size === current.size ? current : next; - }); - }, [renderableFiles]); - - useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); + if (!selectedFilePath) return; + const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); + if (!file) return; + codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); + }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -342,209 +450,226 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff filePath, activeCwd, openInEditor: (targetPath) => { - const api = readLocalApi(); - if (!api) return; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); - }); + void (async () => { + const result = await openInPreferredEditor(targetPath); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to open diff file in editor.", { + operation: "open-diff-file", + ...(routeThreadRef + ? { + environmentId: routeThreadRef.environmentId, + threadId: routeThreadRef.threadId, + } + : {}), + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); }, }); }, - [activeCwd, routeThreadRef], + [activeCwd, openInPreferredEditor, routeThreadRef], + ); + const toggleDiffFileCollapsed = useCallback( + (fileKey: string) => { + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, + [collapseScopeKey], ); - const toggleDiffFileCollapsed = useCallback((fileKey: string) => { - setCollapsedDiffFileKeys((current) => { - const next = new Set(current); - if (next.has(fileKey)) { - next.delete(fileKey); - } else { - next.add(fileKey); - } - return next; - }); - }, []); const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); + const selectGitScope = (scope: "branch" | "unstaged") => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); + }; + const selectBranchBaseRef = (baseRef: string | null) => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> -
- - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - selectTurn(summary.turnId)} - data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} - /> - } + Latest turn + {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && ( + + )} + + + Turn + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + selectTurn(summary.turnId)} + > + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + {summary.turnId === selectedTurn?.turnId && } + + ); + })} + + + + + {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( +
+ {selectedGitSource.headRef ?? "HEAD"} + + { + if (!open) setBaseRefQuery(""); + }} + onValueChange={(value) => { + if (!value) return; + selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); + }} + > + -
-
- - 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} +
+
+ ); + })} +
+ + +
+ )}
Turn diffs are unavailable because this project is not a git repository.
- ) : orderedTurnDiffSummaries.length === 0 ? ( + ) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
No completed turns yet.
) : ( <> -
- {checkpointDiffError && !renderablePatch && ( +
+ {isSelectedPatchTruncated && ( +

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

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

{checkpointDiffError}

+

{selectedPatchError}

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

@@ -652,88 +788,73 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff

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

{renderablePatch.reason}

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

)} {gitStatusError && ( -

{gitStatusError.message}

+

{gitStatusError}

)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx deleted file mode 100644 index 12781005333..00000000000 --- 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 { - getVcsStatusDataForTarget: (state: typeof status) => state.data, - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - -const THREAD_ID = "thread-kb-toast-test" as ThreadId; -const PROJECT_ID = "project-1" as ProjectId; -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: ServerLifecycleWelcomePayload; -} - -let fixture: TestFixture; -const rpcHarness = new BrowserWsRpcHarness(); -const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -const wsLink = ws.link(/ws(s)?:\/\/.*/); - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: LOCAL_ENVIRONMENT_ID, - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [], - slashCommands: [], - skills: [], - }, - ], - availableEditors: [], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: false, - defaultThreadEnvMode: "local" as const, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4-mini", - }, - providers: { - codex: { - enabled: true, - binaryPath: "", - homePath: "", - shadowHomePath: "", - customModels: [], - }, - claudeAgent: { - enabled: true, - binaryPath: "", - homePath: "", - customModels: [], - launchArgs: "", - }, - cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, - grok: { enabled: true, binaryPath: "", customModels: [] }, - opencode: { - enabled: true, - binaryPath: "", - serverUrl: "", - serverPassword: "", - customModels: [], - }, - }, - }, - }; -} - -function createMinimalSnapshot(): OrchestrationReadModel { - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: "Test thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages: [ - { - id: "msg-1" as MessageId, - role: "user", - text: "hello", - turnId: null, - streaming: false, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, - ], - activities: [], - proposedPlans: [], - checkpoints: [], - 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("*/api/assets/*", () => new HttpResponse(null, { status: 204 })), -); - -function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "keybindingsUpdated", - payload: { keybindings: fixture.serverConfig.keybindings, issues }, - }); -} - -function queryToastTitles(): string[] { - return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( - (el) => el.textContent ?? "", - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - return element!; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[data-testid="composer-editor"]'), - "App should render composer editor", - ); -} - -async function waitForToastViewport(): Promise { - return waitForElement( - () => document.querySelector('[data-slot="toast-viewport"]'), - "App should render the toast viewport before server config updates are pushed", - ); -} - -async function waitForWsConnection(): Promise { - await vi.waitFor( - () => { - expect(getWsConnectionStatus().phase).toBe("connected"); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForToast(title: string, count = 1): Promise { - await vi.waitFor( - () => { - const matches = queryToastTitles().filter((t) => t === title); - expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForNoToast(title: string): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); - }, - { timeout: 10_000, interval: 50 }, - ); -} - -async function waitForNoToasts(): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles()).toHaveLength(0); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForInitialWsSubscriptions(): Promise { - await vi.waitFor( - () => { - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), - ).toBe(true); - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigSnapshot(): Promise { - await vi.waitFor( - () => { - expect(getServerConfig()).not.toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigStreamReady(): Promise { - const previousNotificationId = getServerConfigUpdatedNotification()?.id ?? 0; - for (let attempt = 0; attempt < 20; attempt += 1) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "settingsUpdated", - payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, - }); - - try { - await vi.waitFor( - () => { - const notification = getServerConfigUpdatedNotification(); - expect(notification?.id).toBeGreaterThan(previousNotificationId); - expect(notification?.source).toBe("settingsUpdated"); - }, - { timeout: 200, interval: 16 }, - ); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - - throw new Error("Timed out waiting for the server config stream to deliver updates."); -} - -async function mountApp(): Promise<{ cleanup: () => Promise }> { - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.inset = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), - ); - - const screen = await render( - - - , - { container: host }, - ); - await waitForComposerEditor(); - await waitForToastViewport(); - await waitForInitialWsSubscriptions(); - await waitForWsConnection(); - await waitForServerConfigSnapshot(); - await waitForServerConfigStreamReady(); - await waitForNoToasts(); - - return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -describe("Keybindings update toast", () => { - beforeAll(async () => { - fixture = buildFixture(); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { url: "/mockServiceWorker.js" }, - }); - }); - - afterAll(async () => { - await rpcHarness.disconnect(); - await worker.stop(); - }); - - beforeEach(async () => { - await rpcHarness.reset({ - resolveUnary: (request) => resolveWsRpc(request._tag), - getInitialStreamValues: (request) => { - if (request._tag === WS_METHODS.subscribeServerLifecycle) { - return [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - }, - ]; - } - if (request._tag === WS_METHODS.subscribeServerConfig) { - return [ - { - version: 1, - type: "snapshot", - config: encodeServerConfig(fixture.serverConfig), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { - return [ - { - kind: "snapshot", - snapshot: toShellSnapshot(fixture.snapshot), - }, - ]; - } - if ( - request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && - request.threadId === THREAD_ID - ) { - return [ - { - kind: "snapshot", - snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread: fixture.snapshot.threads[0], - }, - }, - ]; - } - return []; - }, - }); - await __resetLocalApiForTests(); - localStorage.clear(); - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated", 1); - - // A single edit can produce several reload notifications as the direct update and - // filesystem watcher settle, so avoid stacking identical success toasts. - sendServerConfigUpdatedPush([]); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const titles = queryToastTitles(); - expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a warning toast when keybinding config has issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([ - { kind: "keybindings.malformed-config", message: "Expected JSON array" }, - ]); - await waitForToast("Invalid keybindings configuration"); - } finally { - await mounted.cleanup(); - } - }); - - it("does not show a toast from the replayed cached value on subscribe", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated"); - await waitForNoToast("Keybindings updated"); - - // Remount the app — onServerConfigUpdated replays the cached value - // synchronously on subscribe. This should NOT produce a toast. - await mounted.cleanup(); - const remounted = await mountApp(); - - // Give it a moment to process the replayed value - await new Promise((resolve) => setTimeout(resolve, 500)); - - const titles = queryToastTitles(); - expect( - titles.filter((t) => t === "Keybindings updated").length, - "Replayed cached value should not produce a toast", - ).toBe(0); - - await remounted.cleanup(); - } catch (error) { - await mounted.cleanup().catch(() => {}); - throw error; - } - }); -}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts new file mode 100644 index 00000000000..de5a2123cde --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -0,0 +1,73 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createKeybindingsUpdateToastController, + KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS, +} from "./KeybindingsUpdateToast.logic"; + +function keybindingsEvent( + overrides: Partial> = {}, +): Extract { + return { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [], + issues: [], + }, + ...overrides, + }; +} + +describe("keybindings update toast policy", () => { + it("coalesces repeated successful reload notifications during the cooldown", () => { + let now = 1_000; + const controller = createKeybindingsUpdateToastController({ + now: () => now, + }); + + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + + now += KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS - 1; + expect(controller.handle(keybindingsEvent())).toBeNull(); + + now += 1; + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + }); + + it("surfaces keybinding configuration issues", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle( + keybindingsEvent({ + payload: { + keybindings: [], + issues: [ + { + kind: "keybindings.malformed-config", + message: "Expected JSON array", + }, + ], + }, + }), + ), + ).toEqual({ + _tag: "InvalidConfiguration", + message: "Expected JSON array", + }); + }); + + it("ignores unrelated server config notifications", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle({ + version: 1, + type: "settingsUpdated", + payload: { settings: {} as never }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts new file mode 100644 index 00000000000..f6a47f50cfc --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -0,0 +1,45 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; + +export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; + +export type KeybindingsUpdateToastDecision = + | { readonly _tag: "Success" } + | { readonly _tag: "InvalidConfiguration"; readonly message: string }; + +export interface KeybindingsUpdateToastController { + readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; +} + +export function createKeybindingsUpdateToastController(input: { + readonly now?: () => number; +}): KeybindingsUpdateToastController { + const now = input.now ?? Date.now; + let lastSuccessToastAt: number | null = null; + + return { + handle: (event) => { + if (event?.type !== "keybindingsUpdated") { + return null; + } + + const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + if (issue) { + return { + _tag: "InvalidConfiguration", + message: issue.message, + }; + } + + const currentTime = now(); + if ( + lastSuccessToastAt !== null && + currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS + ) { + return null; + } + + lastSuccessToastAt = currentTime; + return { _tag: "Success" }; + }, + }; +} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index f7aada1df52..fec255355a5 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,8 @@ import { memo, useState, useCallback } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; @@ -24,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") { @@ -75,6 +80,9 @@ const PlanSidebar = memo(function PlanSidebar({ }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { + reportFailure: false, + }); const { copyToClipboard, isCopied } = useCopyToClipboard(); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; @@ -93,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", @@ -118,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 (
(); @@ -8,38 +8,42 @@ const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; - className?: string; + className?: string | undefined; }) { const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, }); - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", - ); - useEffect(() => { - setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading"); - }, [src]); if (!src) { - return ( - - ); + return ; } + return ; +} + +function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { + return ; +} + +function ProjectFaviconImage({ + src, + className, +}: { + readonly src: string; + readonly className?: string | undefined; +}) { + const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => + loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + ); + return ( <> - {status !== "loaded" ? ( - - ) : null} + {status !== "loaded" ? : null} { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index a9c218c0c9e..4438a671f5d 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -3,6 +3,11 @@ import type { ProjectScriptIcon, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; import { BugIcon, ChevronDownIcon, @@ -91,14 +96,19 @@ export interface NewProjectScriptInput { autoOpenPreview: boolean; } +export type ProjectScriptActionResult = AtomCommandResult; + interface ProjectScriptsControlProps { - scripts: ProjectScript[]; + scripts: ReadonlyArray; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; - onAddScript: (input: NewProjectScriptInput) => Promise | void; - onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; - onDeleteScript: (scriptId: string) => Promise | void; + onAddScript: (input: NewProjectScriptInput) => Promise; + onUpdateScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteScript: (scriptId: string) => Promise; } export default function ProjectScriptsControl({ @@ -161,6 +171,7 @@ export default function ProjectScriptsControl({ } setValidationError(null); + let payload: NewProjectScriptInput; try { const scriptIdForValidation = editingScriptId ?? @@ -173,7 +184,7 @@ export default function ProjectScriptsControl({ command: commandForProjectScript(scriptIdForValidation), }); const trimmedPreviewUrl = previewUrl.trim(); - const payload = { + payload = { name: trimmedName, command: trimmedCommand, icon, @@ -182,16 +193,23 @@ export default function ProjectScriptsControl({ previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; - if (editingScriptId) { - await onUpdateScript(editingScriptId, payload); - } else { - await onAddScript(payload); - } - setDialogOpen(false); - setIconPickerOpen(false); } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); + return; + } + + const result = editingScriptId + ? await onUpdateScript(editingScriptId, payload) + : await onAddScript(payload); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + return; } + setDialogOpen(false); + setIconPickerOpen(false); }; const openAddDialog = () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index aee1ffe9058..8d1f88183fe 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, - firstRejectedProviderUpdateMessage, + firstFailedProviderUpdateMessage, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, @@ -246,12 +248,9 @@ describe("provider update launch notification logic", () => { expect( collectUpdatedProviderSnapshots({ results: [ - { - status: "fulfilled", - value: { - providers: [updatedPersonal, currentDefaultSibling], - }, - }, + AsyncResult.success({ + providers: [updatedPersonal, currentDefaultSibling], + }), ], providerInstanceIds: new Set([targetInstanceId]), }), @@ -435,11 +434,9 @@ describe("provider update launch notification logic", () => { }); it("falls back to a rejected RPC message for transport-level failures", () => { - const results: PromiseSettledResult[] = [ - { status: "rejected", reason: new Error("WebSocket closed") }, - ]; + const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; - expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); + expect(firstFailedProviderUpdateMessage(results)).toBe("WebSocket closed"); expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ phase: "failed", title: "Provider updates failed", @@ -450,9 +447,7 @@ describe("provider update launch notification logic", () => { it("collects only attempted provider snapshots from update responses", () => { const codex = provider({ driver: driver("codex") }); const cursor = provider({ driver: driver("cursor") }); - const results: PromiseSettledResult<{ readonly providers: ReadonlyArray }>[] = [ - { status: "fulfilled", value: { providers: [codex, cursor] } }, - ]; + const results = [AsyncResult.success({ providers: [codex, cursor] })]; expect( collectUpdatedProviderSnapshots({ diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index f45b2916ce4..3f77974e0fe 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -5,6 +5,10 @@ import { type ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; export type ProviderUpdateCandidate = ServerProvider & { readonly versionAdvisory: NonNullable & { @@ -328,14 +332,14 @@ export function getSingleProviderUpdateProgressToastView( export function collectUpdatedProviderSnapshots(input: { readonly results: ReadonlyArray< - PromiseSettledResult<{ readonly providers: ReadonlyArray }> + AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> >; readonly providerInstanceIds: ReadonlySet; }): ServerProvider[] { const matchedProviders: ServerProvider[] = []; for (const result of input.results) { - if (result.status !== "fulfilled") { + if (result._tag === "Failure") { continue; } for (const provider of result.value.providers) { @@ -348,14 +352,15 @@ export function collectUpdatedProviderSnapshots(input: { return dedupeProvidersByInstanceId(matchedProviders); } -export function firstRejectedProviderUpdateMessage( - results: ReadonlyArray>, +export function firstFailedProviderUpdateMessage( + results: ReadonlyArray>, ): string | null { - const rejected = results.find((result) => result.status === "rejected"); - if (!rejected) { + const failed = results.find((result) => result._tag === "Failure"); + if (!failed || failed._tag !== "Failure") { return null; } - return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; + const error = squashAtomCommandFailure(failed); + return error instanceof Error ? error.message : "Provider update failed."; } function getUpdateFinishedAt(provider: ServerProvider): string | null { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 69cd83bf8dc..56814dba1e6 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -1,17 +1,18 @@ import { useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; -import { ensureLocalApi } from "../localApi"; +import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; +import { usePrimaryEnvironment } from "../state/environments"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; -import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, - firstRejectedProviderUpdateMessage, + firstFailedProviderUpdateMessage, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, @@ -20,6 +21,7 @@ import { type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useAtomCommand } from "../state/use-atom-command"; const seenProviderUpdateNotificationKeys = new Set(); type ProviderUpdateToastId = ReturnType; @@ -101,7 +103,11 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); - const providers = useServerProviders(); + const providers = useAtomValue(primaryServerProvidersAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); @@ -185,7 +191,7 @@ export function ProviderUpdateLaunchNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0) { + if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { return; } updateStarted = true; @@ -206,24 +212,30 @@ export function ProviderUpdateLaunchNotification() { openSettings, }); - void Promise.allSettled( - oneClickProviders.map(async (provider) => - ensureLocalApi().server.updateProvider({ - provider: provider.driver, - instanceId: provider.instanceId, - }), - ), - ).then((results) => { + void (async () => { + const results = []; + for (const provider of oneClickProviders) { + results.push( + await updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, + }), + ); + } + const activeUpdateToast = activeToastRef.current; if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { return; } - const rejectedMessage = firstRejectedProviderUpdateMessage(results); - if (rejectedMessage) { + const failedMessage = firstFailedProviderUpdateMessage(results); + if (failedMessage) { updateProviderUpdateToast({ toastId, - view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), + view: getProviderUpdateRejectedToastView(providerCount, failedMessage), openSettings, }); activeToastRef.current = null; @@ -247,7 +259,7 @@ export function ProviderUpdateLaunchNotification() { if (isTerminalProviderUpdateToastView(view)) { activeToastRef.current = null; } - }); + })(); }; toastId = toastManager.add( @@ -288,11 +300,13 @@ export function ProviderUpdateLaunchNotification() { ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; }, [ + updateProvider, dismissNotificationKey, dismissedNotificationKeys, notificationKey, oneClickProviders, openProviderSettings, + primaryEnvironment, updateProviders, ]); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 688ea004f52..4004b4930c2 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -7,10 +8,11 @@ import { usePreparePullRequestThreadAction, usePullRequestResolution, } from "~/lib/sourceControlActions"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { useEnvironmentQuery } from "~/state/query"; +import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { Dialog, @@ -52,7 +54,14 @@ export function PullRequestThreadDialog({ { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const { data: gitStatus } = useVcsStatus({ environmentId, cwd }); + const { data: gitStatus } = useEnvironmentQuery( + cwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd }, + }), + ); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -60,13 +69,6 @@ export function PullRequestThreadDialog({ const terminology = sourceControlPresentation.terminology; const SourceControlIcon = sourceControlPresentation.Icon; - useEffect(() => { - if (!open) return; - setReference(initialReference ?? ""); - setReferenceDirty(false); - setPreparingMode(null); - }, [initialReference, open]); - useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { @@ -137,20 +139,23 @@ export function PullRequestThreadDialog({ return; } setPreparingMode(mode); - try { - const result = await preparePullRequestThreadAction.run({ - reference: parsedReference, - mode, - ...(mode === "worktree" ? { threadId } : {}), - }); - await onPrepared({ - branch: result.branch, - worktreePath: result.worktreePath, - }); - onOpenChange(false); - } finally { - setPreparingMode(null); + const result = await preparePullRequestThreadAction.run({ + reference: parsedReference, + mode, + ...(mode === "worktree" ? { threadId } : {}), + }); + setPreparingMode(null); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + preparePullRequestThreadAction.resetError(); + } + return; } + await onPrepared({ + branch: result.value.branch, + worktreePath: result.value.worktreePath, + }); + onOpenChange(false); }, [ cwd, @@ -173,9 +178,7 @@ export function PullRequestThreadDialog({ const errorMessage = validationMessage ?? (resolvedPullRequest === null && pullRequestResolution.error - ? pullRequestResolution.error instanceof Error - ? pullRequestResolution.error.message - : `Failed to resolve ${terminology.singular}.` + ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error ? preparePullRequestThreadAction.error.message : preparePullRequestThreadAction.error diff --git a/apps/web/src/components/Sidebar.dblclick.browser.tsx b/apps/web/src/components/Sidebar.dblclick.browser.tsx deleted file mode 100644 index 71d744be194..00000000000 --- a/apps/web/src/components/Sidebar.dblclick.browser.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import "../index.css"; - -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { useCallback, useRef, useState } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; -import { cleanup, render } from "vitest-browser-react"; - -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { DEFAULT_INTERACTION_MODE } from "../types"; -import type { SidebarThreadSummary } from "../types"; -import { SidebarThreadRow } from "./Sidebar"; - -// Double-click-to-rename is a desktop affordance; force the non-mobile path so -// the rename input is reachable regardless of the test browser viewport. -vi.mock("~/hooks/useMediaQuery", () => ({ - useIsMobile: () => false, - useMediaQuery: () => false, -})); - -const THREAD_ID = ThreadId.make("thread-1"); -const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const PROJECT_ID = ProjectId.make("project-1"); -const INITIAL_TITLE = "Original title"; - -const ROW_TESTID = `thread-row-${THREAD_ID}`; -const TITLE_TESTID = `thread-title-${THREAD_ID}`; - -// Spies live at module scope so their call history survives the row's -// re-renders; reset between tests. -const spies = { - handleThreadClick: vi.fn(), - startThreadRename: vi.fn(), - navigateToThread: vi.fn(), - handleMultiSelectContextMenu: vi.fn(async () => {}), - handleThreadContextMenu: vi.fn(async () => {}), - clearSelection: vi.fn(), - commitRename: vi.fn(), - attemptArchiveThread: vi.fn(async () => {}), - openPrLink: vi.fn(), -}; - -function buildThread(title: string): SidebarThreadSummary { - return { - id: THREAD_ID, - environmentId: ENVIRONMENT_ID, - projectId: PROJECT_ID, - title, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2024-01-01T00:00:00.000Z", - archivedAt: null, - updatedAt: undefined, - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }; -} - -// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires -// `startThreadRename`, and commits by clearing the rename state and persisting -// the new title back onto the thread so the row re-renders with it. -function Harness() { - const [title, setTitle] = useState(INITIAL_TITLE); - const [renamingThreadKey, setRenamingThreadKey] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); - const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); - const renamingInputRef = useRef(null); - const renamingCommittedRef = useRef(false); - const confirmArchiveButtonRefs = useRef(new Map()); - - const startThreadRename = useCallback((threadKey: string, nextTitle: string) => { - spies.startThreadRename(threadKey, nextTitle); - setRenamingThreadKey(threadKey); - setRenamingTitle(nextTitle); - renamingCommittedRef.current = false; - }, []); - - const commitRename = useCallback( - async (threadRef: unknown, newTitle: string, originalTitle: string) => { - spies.commitRename(threadRef, newTitle, originalTitle); - const trimmed = newTitle.trim(); - if (trimmed.length > 0) { - setTitle(trimmed); - } - setRenamingThreadKey(null); - renamingInputRef.current = null; - }, - [], - ); - - const cancelRename = useCallback(() => { - setRenamingThreadKey(null); - renamingInputRef.current = null; - }, []); - - return ( - -
    - -
-
- ); -} - -describe("SidebarThreadRow double-click rename", () => { - beforeEach(() => { - for (const spy of Object.values(spies)) spy.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it("double-clicking a row starts the inline rename, focused with text selected", async () => { - render(); - - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - const element = input.element() as HTMLInputElement; - expect(element.value).toBe(INITIAL_TITLE); - // The existing rename-input ref focuses + selects the whole title. - expect(document.activeElement).toBe(element); - expect(element.selectionStart).toBe(0); - expect(element.selectionEnd).toBe(INITIAL_TITLE.length); - }); - - it("Enter commits the rename and the new title persists on the row", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - await userEvent.fill(input, "Renamed thread"); - await userEvent.keyboard("{Enter}"); - - // commitRename was invoked with (threadRef, newTitle, originalTitle). - expect(spies.commitRename).toHaveBeenCalledTimes(1); - expect(spies.commitRename).toHaveBeenCalledWith( - expect.anything(), - "Renamed thread", - INITIAL_TITLE, - ); - - // Input is gone and the row now shows the persisted title. - const title = page.getByTestId(TITLE_TESTID); - await expect.element(title).toBeVisible(); - await expect.element(title).toHaveTextContent("Renamed thread"); - }); - - it("Escape cancels the rename without committing", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - await expect.element(page.getByRole("textbox")).toBeVisible(); - - await userEvent.keyboard("{Escape}"); - - expect(spies.commitRename).not.toHaveBeenCalled(); - const title = page.getByTestId(TITLE_TESTID); - await expect.element(title).toBeVisible(); - await expect.element(title).toHaveTextContent(INITIAL_TITLE); - }); - - it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - - await userEvent.fill(input, "Edited but not committed"); - // Double-clicking inside the input (e.g. to select a word) must not bubble - // to the row and restart the rename, which would wipe the edit. - await userEvent.dblClick(input); - - expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed"); - expect(spies.commitRename).not.toHaveBeenCalled(); - }); - - it("double-clicking the row chrome while already renaming does not restart/reset it", async () => { - render(); - - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - const input = page.getByRole("textbox"); - await expect.element(input).toBeVisible(); - await userEvent.fill(input, "Edited"); - expect(spies.startThreadRename).toHaveBeenCalledTimes(1); - - // Double-click the row element itself (chrome, not the input). - const rowEl = page.getByTestId(ROW_TESTID).element(); - rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 })); - - // Guard short-circuits: rename is not restarted and the edit is preserved. - expect(spies.startThreadRename).toHaveBeenCalledTimes(1); - expect((input.element() as HTMLInputElement).value).toBe("Edited"); - }); - - it("modifier double-click is multi-select intent and does not start a rename", async () => { - render(); - - await userEvent.keyboard("{Shift>}"); - await userEvent.dblClick(page.getByTestId(ROW_TESTID)); - await userEvent.keyboard("{/Shift}"); - - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - expect(page.getByRole("textbox").elements()).toHaveLength(0); - }); - - it("single click routes through the navigation handler and does not start a rename", async () => { - render(); - - await userEvent.click(page.getByTestId(ROW_TESTID)); - - expect(spies.handleThreadClick).toHaveBeenCalledTimes(1); - // No rename input: the title span is still shown. - await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); - expect(page.getByRole("textbox").elements()).toHaveLength(0); - }); -}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fc6cbd1c0ed..574e33d4dab 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; - import { createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -16,6 +14,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -38,6 +37,44 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("resolveSidebarStageBadgeLabel", () => { + it("returns Nightly for nightly primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); + }); + + it("returns the fallback label for stable primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.27", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); + + it("returns the fallback label when the primary server version is missing", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: null, + fallbackStageLabel: "Dev", + }), + ).toBe("Dev"); + }); + + it("returns the fallback label for malformed nightly prerelease versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); +}); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -66,6 +103,20 @@ describe("hasUnseenCompletion", () => { }), ).toBe(true); }); + + it("treats a missing client visit marker as read", () => { + expect( + hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: makeLatestTurn(), + lastVisitedAt: undefined, + session: null, + }), + ).toBe(false); + }); }); describe("createThreadJumpHintVisibilityController", () => { @@ -225,6 +276,7 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/draft", worktreePath: "/repo/.t3/worktrees/draft", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ @@ -266,12 +318,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ branch: "feature/new-draft", worktreePath: "/repo/worktree", envMode: "worktree", + startFromOrigin: true, }); }); @@ -346,17 +400,17 @@ describe("orderItemsByPreferredIds", () => { { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-alpha"), - cwd: "/work/alpha", + workspaceRoot: "/work/alpha", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-beta"), - cwd: "/work/beta", + workspaceRoot: "/work/beta", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-gamma"), - cwd: "/work/gamma", + workspaceRoot: "/work/gamma", }, ]; const ordered = orderItemsByPreferredIds({ @@ -365,12 +419,31 @@ describe("orderItemsByPreferredIds", () => { getId: getProjectOrderKey, }); - expect(ordered.map((project) => project.cwd)).toEqual([ + expect(ordered.map((project) => project.workspaceRoot)).toEqual([ "/work/gamma", "/work/alpha", "/work/beta", ]); }); + + it("resolves legacy preference aliases without materializing project state", () => { + const ordered = orderItemsByPreferredIds({ + items: [ + { id: "physical-a", cwd: "/work/a" }, + { id: "physical-b", cwd: "/work/b" }, + { id: "physical-c", cwd: "/work/c" }, + ], + preferredIds: ["legacy:/work/c", "legacy:/work/a"], + getId: (project) => project.id, + getPreferenceIds: (project) => [project.id, `legacy:${project.cwd}`], + }); + + expect(ordered.map((project) => project.id)).toEqual([ + "physical-c", + "physical-a", + "physical-b", + ]); + }); }); describe("resolveAdjacentThreadId", () => { @@ -500,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, }, }; @@ -549,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: { @@ -565,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", () => { @@ -583,7 +659,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -721,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", @@ -739,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: { @@ -752,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, }; @@ -834,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({ @@ -846,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", }, ], }), @@ -861,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", }, ], }), @@ -882,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", }), ], @@ -906,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, }), ], [], @@ -929,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"); @@ -946,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 41f4e39bb73..4e7614ed551 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,6 +9,7 @@ import { import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -18,7 +19,7 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; - name: string; + title: string; createdAt?: string | undefined; updatedAt?: string | undefined; }; @@ -64,6 +65,13 @@ export interface ThreadJumpHintVisibilityController { dispose: () => void; } +export function resolveSidebarStageBadgeLabel(input: { + primaryServerVersion: string | null | undefined; + fallbackStageLabel: string; +}): string { + return resolveServerBackedAppStageLabel(input); +} + export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; @@ -148,7 +156,7 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return true; + if (!thread.lastVisitedAt) return false; const lastVisitedAt = Date.parse(thread.lastVisitedAt); if (Number.isNaN(lastVisitedAt)) return true; @@ -189,11 +197,13 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: string | null; worktreePath: string | null; envMode: SidebarNewThreadEnvMode; + startFromOrigin: boolean; } | null; }): { branch?: string | null; worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; + startFromOrigin?: boolean; } { if (input.defaultEnvMode === "worktree") { return { @@ -206,6 +216,7 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + startFromOrigin: input.activeDraftThread.startFromOrigin, }; } @@ -226,27 +237,38 @@ export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; getId: (item: TItem) => TId; + getPreferenceIds?: (item: TItem) => readonly TId[]; }): TItem[] { - const { getId, items, preferredIds } = input; + const { getId, getPreferenceIds, items, preferredIds } = input; if (preferredIds.length === 0) { return [...items]; } - const itemsById = new Map(items.map((item) => [getId(item), item] as const)); - const preferredIdSet = new Set(preferredIds); - const emittedPreferredIds = new Set(); - const ordered = preferredIds.flatMap((id) => { - if (emittedPreferredIds.has(id)) { - return []; + const indexesByPreferenceId = new Map(); + for (const [index, item] of items.entries()) { + const preferenceIds = getPreferenceIds?.(item) ?? [getId(item)]; + for (const preferenceId of new Set(preferenceIds)) { + const indexes = indexesByPreferenceId.get(preferenceId); + if (indexes) { + indexes.push(index); + } else { + indexesByPreferenceId.set(preferenceId, [index]); + } } - const item = itemsById.get(id); - if (!item) { + } + + const emittedIndexes = new Set(); + const ordered = preferredIds.flatMap((id) => { + const index = indexesByPreferenceId + .get(id) + ?.find((candidate) => !emittedIndexes.has(candidate)); + if (index === undefined) { return []; } - emittedPreferredIds.add(id); - return [item]; + emittedIndexes.add(index); + return [items[index]!]; }); - const remaining = items.filter((item) => !preferredIdSet.has(getId(item))); + const remaining = items.filter((_, index) => !emittedIndexes.has(index)); return [...ordered, ...remaining]; } @@ -367,7 +389,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "connecting") { + if (thread.session?.status === "starting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", @@ -545,6 +567,6 @@ export function sortProjectsForSidebar< const byTimestamp = rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; - return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + return left.title.localeCompare(right.title) || left.id.localeCompare(right.id); }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9e6ff1c34cb..f3ed88bd3b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,8 +17,10 @@ import { resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, + ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -39,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, - type DesktopUpdateState, + DEFAULT_SERVER_SETTINGS, ProjectId, type ScopedThreadRef, + type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, - type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; import { @@ -52,7 +54,13 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -61,23 +69,30 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; -import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { - selectProjectByRef, - selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, - selectThreadByRef, - useStore, -} from "../store"; + readThreadShell, + useProject, + useProjects, + useServerConfigs, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { useUiStateStore } from "../uiStateStore"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; +import { previewEnvironment } from "../state/preview"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -86,15 +101,19 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { useModelPickerOpen } from "../modelPickerOpenState"; +import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { useVcsStatus } from "../lib/vcsStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -159,7 +178,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -173,6 +192,7 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -181,19 +201,14 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; -import { readEnvironmentApi } from "../environmentApi"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "../rpc/serverState"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, @@ -202,7 +217,6 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -225,6 +239,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record = const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -237,10 +256,20 @@ function formatProjectMemberActionLabel( groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { - return member.name; + return member.title; } - return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; +} + +function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { + return [ + project.projectKey, + ...project.memberProjects.map((member) => member.physicalProjectKey), + ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; } function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { @@ -255,7 +284,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; + keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< @@ -361,35 +390,60 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr environmentId: thread.environmentId, threadId: thread.id, }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = environment?.label ?? null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. - const threadProjectCwd = useStore( + const threadProject = useProject( useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, + () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useVcsStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ @@ -431,17 +485,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); - const handleOpenDiscoveredPort = useCallback( - (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void openDiscoveredPort({ threadRef, port }); - }, - [discoveredPorts, navigateToThread, threadRef], - ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the @@ -473,20 +516,48 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); const hasSelection = useThreadSelectionStore.getState().hasSelection(); if (hasSelection && isSelected) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); + void (async () => { + const result = await settlePromise(() => + handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); return; } if (hasSelection) { clearSelection(); } - void handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }); + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); }, [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], ); @@ -675,6 +746,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} + {terminalStatus && ( ["handleNewThread"]; + handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; @@ -1014,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); @@ -1080,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( @@ -1128,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) => @@ -1215,7 +1265,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1313,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, ], ); @@ -1332,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( @@ -1357,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( @@ -1372,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( @@ -1421,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}`] : []), @@ -1440,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}`] : []), @@ -1452,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, }), ); @@ -1477,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"); @@ -1487,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, }), ); @@ -1535,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); @@ -1728,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); }, @@ -1750,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 = @@ -1762,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 @@ -1779,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( @@ -1811,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; } @@ -1838,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", @@ -1888,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", @@ -1911,7 +2019,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [updateThreadMetadata], ); const closeProjectRenameDialog = useCallback(() => { @@ -1933,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", @@ -1967,7 +2065,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -2010,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" }, @@ -2061,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, @@ -2070,7 +2179,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, memberProjectByScopedKey, - project.cwd, + project.workspaceRoot, startThreadRename, ], ); @@ -2119,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} @@ -2187,7 +2296,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec showEmptyThreadState={showEmptyThreadState} shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} - projectCwd={project.cwd} + projectCwd={project.workspaceRoot} activeRouteThreadKey={activeRouteThreadKey} threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} @@ -2227,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."} @@ -2274,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."} @@ -2555,6 +2664,12 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const stageBadgeLabel = resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); const wordmark = (
@@ -2571,7 +2686,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ Code - {APP_STAGE_LABEL} + {stageBadgeLabel} } @@ -2632,7 +2747,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType["updateSettings"]; + updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -2640,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[]; @@ -2893,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({ @@ -2915,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()); @@ -2924,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]); @@ -2966,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)), @@ -3031,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( () => ({ @@ -3102,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( @@ -3185,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 @@ -3236,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({ @@ -3284,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]); @@ -3382,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) @@ -3520,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 00000000000..07711ca84b7 --- /dev/null +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; + +import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; +import { toastManager } from "./ui/toast"; + +function describeSlowRequests(requests: ReadonlyArray): string { + const count = requests.length; + const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + + return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; +} + +function SlowRequestDetails({ requests }: { requests: ReadonlyArray }) { + return ( +
    + {requests.map((request) => ( +
  • +
    {request.tag}
    +
    + Started {new Date(request.startedAt).toLocaleTimeString()} +
    +
  • + ))} +
+ ); +} + +export function SlowRpcRequestToastCoordinator() { + const slowRequests = useSlowRpcAckRequests(); + const toastIdRef = useRef | null>(null); + + useEffect(() => { + if (slowRequests.length === 0) { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + toastIdRef.current = null; + } + return; + } + + const nextToast = { + data: { + expandableContent: , + expandableDescriptionTrigger: true, + expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, + }, + description: describeSlowRequests(slowRequests), + timeout: 0, + title: "Some requests are slow", + type: "warning" as const, + }; + + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, [slowRequests]); + + useEffect( + () => () => { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + } + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx new file mode 100644 index 00000000000..868bd2cd99c --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -0,0 +1,39 @@ +import { ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; + +describe("ThreadWorktreeIndicator", () => { + it("renders the worktree folder and branch in an accessible label", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="img"'); + expect(markup).toContain( + 'aria-label="Worktree: sidebar-indicator (feature/sidebar-indicator)"', + ); + expect(markup).toContain('data-testid="thread-worktree-thread-1"'); + }); + + it.each([null, "", " "])("renders nothing for an absent worktree path", (worktreePath) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe(""); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ed2df1c79a0..3e85920d190 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 5db71b630c9..00000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ /dev/null @@ -1,425 +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", () => ({ - ensureEnvironmentApi: (environmentId: string) => { - const api = readEnvironmentApiMock(environmentId); - if (!api) { - throw new Error(`Environment API not found for ${environmentId}`); - } - return api; - }, - readEnvironmentApi: readEnvironmentApiMock, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: readLocalApiMock, -})); - -import { TerminalViewport } from "./ThreadTerminalDrawer"; - -const THREAD_ID = ThreadId.make("thread-terminal-browser"); - -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 4972e07bbc2..1641bb6b109 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,10 @@ +import { useAtomValue } from "@effect/atom-react"; import { FitAddon } from "@xterm/addon-fit"; import { - Globe2, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Plus, SquareSplitHorizontal, SquareSplitVertical, @@ -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, @@ -30,7 +33,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, @@ -55,12 +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 { useDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -81,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); } } @@ -307,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); @@ -322,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; @@ -331,10 +382,7 @@ export function TerminalViewport({ const mount = containerRef.current; if (!mount) return; - let disposed = false; - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -352,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; @@ -435,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); } }; @@ -517,12 +571,15 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; - } if (match.kind === "url") { + if (!localApi) { + writeSystemMessage( + latestTerminal, + "Opening links is unavailable in this browser.", + ); + return; + } const fallbackToBrowser = () => { void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( @@ -531,16 +588,11 @@ export function TerminalViewport({ ); }); }; - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - fallbackToBrowser(); - return; - } void openTerminalLinkInPreview({ url: match.text, position: { x: event.clientX, y: event.clientY }, threadRef, - api, + openPreview, localApi, fallbackToBrowser, }); @@ -548,12 +600,17 @@ export function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(localApi, target).catch((error) => { + void (async () => { + const result = await openTerminalPath(target); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", ); - }); + })(); }, })), ); @@ -561,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(() => { @@ -614,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; @@ -725,54 +684,11 @@ export function TerminalViewport({ if (wasAtBottom) { activeTerminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(activeTerminal.cols, activeTerminal.rows); }, 30); - attachTerminal(); - let resizeFrame = 0; - const resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(() => { - if (resizeFrame !== 0) return; - resizeFrame = window.requestAnimationFrame(() => { - resizeFrame = 0; - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - fitTerminalSafely(activeFitAddon); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); - }); - }); - resizeObserver?.observe(mount); return () => { - disposed = true; - unsubscribeAttach?.(); - unsubscribeAttach = null; window.clearTimeout(fitTimer); - if (resizeFrame !== 0) { - window.cancelAnimationFrame(resizeFrame); - } - resizeObserver?.disconnect(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -791,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; @@ -804,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); @@ -926,10 +893,32 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const controlledDrawerHeight = clampDrawerHeight(height); + const [drawerHeightState, setDrawerHeightState] = useState(() => ({ + threadId, + height: controlledDrawerHeight, + })); + const drawerHeight = + drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; + const setDrawerHeight = useCallback( + (update: SetStateAction) => { + setDrawerHeightState((current) => { + const currentHeight = + current.threadId === threadId ? current.height : controlledDrawerHeight; + const nextHeight = typeof update === "function" ? update(currentHeight) : update; + return nextHeight === currentHeight && current.threadId === threadId + ? current + : { threadId, height: nextHeight }; + }); + }, + [controlledDrawerHeight, threadId], + ); + const setDrawerHeightFromWindowResize = useEffectEvent((nextHeight: number) => { + setDrawerHeight(nextHeight); + }); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; @@ -1060,17 +1049,6 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); - const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); - const discoveredPortByTerminalId = useMemo(() => { - const next = new Map(); - for (const port of discoveredPorts) { - if (port.terminal?.threadId !== threadId) continue; - if (!next.has(port.terminal.terminalId)) { - next.set(port.terminal.terminalId, port); - } - } - return next; - }, [discoveredPorts, threadId]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1127,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; @@ -1145,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) => { @@ -1186,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) { @@ -1474,7 +1452,6 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; - const discoveredPort = discoveredPortByTerminalId.get(terminalId); const closeTerminalLabel = `Close ${ terminalLabelById.get(terminalId) ?? "terminal" }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; @@ -1500,37 +1477,6 @@ export default function ThreadTerminalDrawer({ {terminalLabelById.get(terminalId) ?? "Terminal"} - {discoveredPort && ( - - - void openDiscoveredPort({ - threadRef, - port: discoveredPort, - }) - } - aria-label={`Open localhost:${discoveredPort.port}`} - /> - } - > - - - - Open localhost:{discoveredPort.port} - - - )} {normalizedTerminalIds.length > 1 && ( = {}): WsConnectionStatus { - return { - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: true, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: 8, - reconnectPhase: "idle", - socketUrl: null, - ...overrides, - }; -} - -describe("WebSocketConnectionSurface.logic", () => { - it("forces reconnect on online when the app was offline", () => { - expect( - shouldAutoReconnect( - makeStatus({ - disconnectedAt: "2026-04-03T20:00:00.000Z", - online: false, - phase: "disconnected", - }), - "online", - ), - ).toBe(true); - }); - - it("forces reconnect on focus only for previously connected disconnected states", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(true); - - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: false, - online: true, - phase: "disconnected", - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(false); - }); - - it("forces reconnect on focus for exhausted reconnect loops", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 8, - reconnectPhase: "exhausted", - }), - "focus", - ), - ).toBe(true); - }); - - it("restarts a stalled reconnect window after the scheduled retry time passes", () => { - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(true); - - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "attempting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx deleted file mode 100644 index b54bd865c8b..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; - -import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - setBrowserOnlineStatus, - type WsConnectionStatus, - type WsConnectionUiState, - useWsConnectionStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "../rpc/wsConnectionState"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { getPrimaryEnvironmentConnection } from "../environments/runtime"; - -const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; -type WsAutoReconnectTrigger = "focus" | "online"; - -const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { - day: "numeric", - hour: "numeric", - minute: "2-digit", - month: "short", - second: "2-digit", -}); - -function formatConnectionMoment(isoDate: string | null): string | null { - if (!isoDate) { - return null; - } - - return connectionTimeFormatter.format(new Date(isoDate)); -} - -function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { - const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); - return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; -} - -function describeOfflineToast(): string { - return "WebSocket disconnected. Waiting for network."; -} - -function formatReconnectAttemptLabel(status: WsConnectionStatus): string { - const reconnectAttempt = Math.max( - 1, - Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), - ); - return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; -} - -function describeExhaustedToast(): string { - return "Retries exhausted trying to reconnect"; -} - -function getConnectionDisplayName(status: WsConnectionStatus): string { - return status.connectionLabel?.trim() || "T3 Server"; -} - -function buildReconnectTitle(status: WsConnectionStatus): string { - return `Disconnected from ${getConnectionDisplayName(status)}`; -} - -function buildRecoveredTitle(status: WsConnectionStatus): string { - return `Reconnected to ${getConnectionDisplayName(status)}`; -} - -function describeRecoveredToast( - previousDisconnectedAt: string | null, - connectedAt: string | null, -): string { - const reconnectedAtLabel = formatConnectionMoment(connectedAt); - const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); - - if (disconnectedAtLabel && reconnectedAtLabel) { - return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; - } - - if (reconnectedAtLabel) { - return `Connection restored at ${reconnectedAtLabel}.`; - } - - return "Connection restored."; -} - -function describeSlowRpcAckToast(requests: ReadonlyArray): string { - const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); - - return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; -} - -function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { - return ( -
    - {requests.map((req) => ( -
  • -
    {req.tag}
    -
    - {req.requestId} -
    -
    - Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} -
    -
  • - ))} -
- ); -} - -export function shouldAutoReconnect( - status: WsConnectionStatus, - trigger: WsAutoReconnectTrigger, -): boolean { - const uiState = getWsConnectionUiState(status); - - if (trigger === "online") { - return ( - uiState === "offline" || - uiState === "reconnecting" || - uiState === "error" || - status.reconnectPhase === "exhausted" - ); - } - - return ( - status.online && - status.hasConnected && - (uiState === "reconnecting" || status.reconnectPhase === "exhausted") - ); -} - -export function shouldRestartStalledReconnect( - status: WsConnectionStatus, - expectedNextRetryAt: string, -): boolean { - return ( - status.reconnectPhase === "waiting" && - status.nextRetryAt === expectedNextRetryAt && - status.online && - status.hasConnected - ); -} - -export function WebSocketConnectionCoordinator() { - const status = useWsConnectionStatus(); - const [nowMs, setNowMs] = useState(() => Date.now()); - const lastForcedReconnectAtRef = useRef(0); - const toastIdRef = useRef | null>(null); - const toastResetTimerRef = useRef(null); - const previousUiStateRef = useRef(getWsConnectionUiState(status)); - const previousDisconnectedAtRef = useRef(status.disconnectedAt); - - const runReconnect = useEffectEvent((showFailureToast: boolean) => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - lastForcedReconnectAtRef.current = Date.now(); - void getPrimaryEnvironmentConnection() - .reconnect() - .catch((error) => { - if (!showFailureToast) { - console.warn("Automatic WebSocket reconnect failed", { error }); - return; - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Reconnect failed", - description: - error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }), - ); - }); - }); - const syncBrowserOnlineStatus = useEffectEvent(() => { - setBrowserOnlineStatus(navigator.onLine !== false); - }); - const triggerManualReconnect = useEffectEvent(() => { - runReconnect(true); - }); - const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { - const currentStatus = - trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); - - if (!shouldAutoReconnect(currentStatus, trigger)) { - return; - } - if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { - return; - } - - runReconnect(false); - }); - - useEffect(() => { - const handleOnline = () => { - triggerAutoReconnect("online"); - }; - const handleFocus = () => { - triggerAutoReconnect("focus"); - }; - - syncBrowserOnlineStatus(); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", syncBrowserOnlineStatus); - window.addEventListener("focus", handleFocus); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", syncBrowserOnlineStatus); - window.removeEventListener("focus", handleFocus); - }; - }, []); - - useEffect(() => { - if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { - return; - } - - setNowMs(Date.now()); - const intervalId = window.setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - - return () => { - window.clearInterval(intervalId); - }; - }, [status.nextRetryAt, status.reconnectPhase]); - - useEffect(() => { - if ( - status.reconnectPhase !== "waiting" || - status.nextRetryAt === null || - !status.online || - !status.hasConnected - ) { - return; - } - - const nextRetryAt = status.nextRetryAt; - const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; - const timeoutId = window.setTimeout(() => { - const currentStatus = getWsConnectionStatus(); - if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { - return; - } - - runReconnect(false); - }, timeoutMs); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [ - status.hasConnected, - status.nextRetryAt, - status.online, - status.reconnectAttemptCount, - status.reconnectPhase, - ]); - - useEffect(() => { - const uiState = getWsConnectionUiState(status); - const previousUiState = previousUiStateRef.current; - const previousDisconnectedAt = previousDisconnectedAtRef.current; - const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; - const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; - const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; - - if ( - toastResetTimerRef.current !== null && - (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) - ) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - - if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { - const toastPayload = shouldShowOfflineToast - ? stackedThreadToast({ - data: { - hideCopyButton: true, - }, - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning", - }) - : shouldShowExhaustedToast - ? stackedThreadToast({ - actionProps: { - children: "Retry", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: describeExhaustedToast(), - timeout: 0, - title: buildReconnectTitle(status), - type: "error", - }) - : stackedThreadToast({ - actionProps: { - children: "Retry now", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: - status.nextRetryAt === null - ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` - : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, - timeout: 0, - title: buildReconnectTitle(status), - type: "loading", - }); - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, toastPayload); - } else { - toastIdRef.current = toastManager.add(toastPayload); - } - } else if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - - if ( - uiState === "connected" && - (previousUiState === "offline" || previousUiState === "reconnecting") && - previousDisconnectedAt !== null - ) { - const successToast = { - description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: buildRecoveredTitle(status), - type: "success" as const, - timeout: 0, - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, successToast); - } else { - toastIdRef.current = toastManager.add(successToast); - } - - toastResetTimerRef.current = window.setTimeout(() => { - toastIdRef.current = null; - toastResetTimerRef.current = null; - }, 8_250); - } - - previousUiStateRef.current = uiState; - previousDisconnectedAtRef.current = status.disconnectedAt; - }, [nowMs, status]); - - useEffect(() => { - return () => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - } - }; - }, []); - - return null; -} - -export function SlowRpcAckToastCoordinator() { - const slowRequests = useSlowRpcAckRequests(); - const status = useWsConnectionStatus(); - const toastIdRef = useRef | null>(null); - - useEffect(() => { - if (getWsConnectionUiState(status) !== "connected") { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - if (slowRequests.length === 0) { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - const nextToast = { - data: { - expandableContent: , - expandableDescriptionTrigger: true, - expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, - }, - description: describeSlowRpcAckToast(slowRequests), - timeout: 0, - title: "Some requests are slow", - type: "warning" as const, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, nextToast); - } else { - toastIdRef.current = toastManager.add(nextToast); - } - }, [slowRequests, status]); - - return null; -} - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 65e9c6dd8eb..59288506569 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -1,8 +1,9 @@ import type { AuthSessionState } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; -import { addSavedEnvironment } from "../../environments/runtime"; +import { connectPairing } from "../../connection/onboarding"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, @@ -11,6 +12,7 @@ import { import { readHostedPairingRequest } from "../../hostedPairing"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { useAtomCommand } from "../../state/use-atom-command"; export function PairingPendingSurface() { return ( @@ -162,6 +164,9 @@ export function PairingRouteSurface({ } export function HostedPairingRouteSurface() { + const connectPairingEnvironment = useAtomCommand(connectPairing, { + reportFailure: false, + }); const hostedPairingRequestRef = useRef(readHostedPairingRequest()); const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => hostedPairingRequestRef.current ? "pairing" : "error", @@ -197,23 +202,23 @@ export function HostedPairingRouteSurface() { setCanRetry(false); tokenSubmittedRef.current = true; - try { - const record = await addSavedEnvironment({ - label: request.label, - host: request.host, - pairingCode: request.token, - }); + const result = await connectPairingEnvironment({ + host: request.host, + pairingCode: request.token, + }); + if (result._tag === "Success") { setStatus("paired"); - setMessage(`${record.label} is saved in this browser.`); - } catch (error) { - tokenSubmittedRef.current = false; - setStatus("error"); - setCanRetry(true); - setMessage( - `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, - ); + setMessage(`${request.label || "The environment"} is saved in this browser.`); + return; } - }, []); + + tokenSubmittedRef.current = false; + setStatus("error"); + setCanRetry(true); + setMessage( + `${errorMessageFromUnknown(squashAtomCommandFailure(result))} If the backend accepted this one-time token, request a new pairing link before retrying.`, + ); + }, [connectPairingEnvironment]); useEffect(() => { if (submitAttemptedRef.current) { diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 1eca82dbd9b..c371acdb362 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -9,8 +9,8 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src"], hiddenLabels: ["index.ts", "main.ts"], @@ -18,8 +18,18 @@ describe("ChangedFilesTree", () => { { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: ["apps/server/src"], hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], @@ -27,9 +37,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: ["README.md", "packages"], hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], @@ -60,16 +75,26 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src", "index.ts", "main.ts"], }, { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: [ "apps/server/src", @@ -82,9 +107,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: [ "README.md", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6908202e70a..3a5e06bce06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,6 +18,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -101,6 +105,7 @@ import { import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, @@ -443,7 +448,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -509,7 +514,7 @@ export interface ChatComposerProps { onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; @@ -658,8 +663,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. const providerInstanceEntries = useMemo>( - () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - [providerStatuses], + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ), + [providerStatuses, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = @@ -2419,11 +2427,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label} is ${ - environmentUnavailable.connectionState === "connecting" - ? "connecting" - : "disconnected" - }` + ? `${environmentUnavailable.label}: ${connectionStatusText( + environmentUnavailable.connection, + )}` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 2bfc204cec7..efc160b0bd1 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,15 +5,18 @@ import { type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; +import ProjectScriptsControl, { + type NewProjectScriptInput, + type ProjectScriptActionResult, +} from "../ProjectScriptsControl"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; -import { usePrimaryEnvironmentId } from "../../environments/primary/context"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -23,16 +26,19 @@ interface ChatHeaderProps { activeThreadTitle: string; activeProjectName: string | undefined; openInCwd: string | null; - activeProjectScripts: ProjectScript[] | undefined; + activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + rightPanelOpen: boolean; gitCwd: string | null; onRunProjectScript: (script: ProjectScript) => void; - onAddProjectScript: (input: NewProjectScriptInput) => Promise; - onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; - onDeleteProjectScript: (scriptId: string) => Promise; - rightPanelOpen: boolean; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; } export function shouldShowOpenInPicker(input: { @@ -58,12 +64,12 @@ export const ChatHeader = memo(function ChatHeader({ preferredScriptId, keybindings, availableEditors, + rightPanelOpen, gitCwd, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - rightPanelOpen, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const showOpenInPicker = shouldShowOpenInPicker({ @@ -109,6 +115,7 @@ export const ChatHeader = memo(function ChatHeader({ )} {showOpenInPicker && ( , - promptInjectedValues?: ReadonlyArray, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - ...(promptInjectedValues && promptInjectedValues.length > 0 - ? { promptInjectedValues: [...promptInjectedValues] } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string) { - return { - id, - label, - type: "boolean" as const, - }; -} - -async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { - const threadId = ThreadId.make("thread-compact-menu"); - const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); - const threadKey = scopedThreadKey(threadRef); - const provider = ProviderDriverKind.make("claudeAgent"); - const instanceId = ProviderInstanceId.make(props?.modelSelection?.instanceId ?? provider); - const model = - props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; - - useComposerDraftStore.setState({ - draftsByThreadKey: { - // Compose from the canonical empty-draft factory so adding a new - // ComposerThreadDraftState slice (e.g. a future attachment kind) doesn't - // silently break this stub via `Property X is missing in type ...`. - [threadKey]: { - ...createEmptyThreadDraft(), - prompt: props?.prompt ?? "", - modelSelectionByProvider: { - [instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options), - }, - activeProvider: instanceId, - }, - }, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; - const models = [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor( - "effort", - "Reasoning", - [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - { id: "max", label: "Max" }, - { id: "ultrathink", label: "Ultrathink" }, - ], - ["ultrathink"], - ), - booleanDescriptor("fastMode", "Fast Mode"), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [booleanDescriptor("thinking", "Thinking")], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor( - "effort", - "Reasoning", - [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - { id: "ultrathink", label: "Ultrathink" }, - ], - ["ultrathink"], - ), - ], - }), - }, - ]; - const screen = await render( - - } - onToggleInteractionMode={vi.fn()} - onTogglePlanSidebar={vi.fn()} - onRuntimeModeChange={vi.fn()} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("CompactComposerControlsMenu", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls for Opus", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("On"); - expect(text).toContain("Off"); - }); - }); - - it("hides fast mode controls for non-Opus Claude models", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - }); - - it("shows a Claude thinking on/off section for Haiku", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", - [{ id: "thinking", value: true }], - ), - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On"); - expect(text).toContain("Off"); - }); - }); - - it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "effort", value: "high" }], - ), - prompt: "Ultrathink:\nInvestigate this", - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Reasoning"); - expect(text).not.toContain("ultrathink"); - }); - }); - - it("warns when ultrathink appears in prompt body text", async () => { - await using _ = await mountMenu({ - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "effort", value: "high" }], - ), - prompt: "Ultrathink:\nplease ultrathink about this problem", - }); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', - ); - }); - }); - - it("can hide the interaction mode section", async () => { - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { container: host }, - ); - - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).not.toContain("Mode"); - expect(text).not.toContain("Chat"); - expect(text).not.toContain("Plan"); - expect(text).toContain("Access"); - expect(text).toContain("Supervised"); - expect(text).toContain("Full access"); - }); - - await screen.unmount(); - host.remove(); - }); -}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 9901237fdf0..4a2a8f29dfc 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -40,14 +40,12 @@ interface ComposerBannerStackProps { } export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { - const [exitingItemId, setExitingItemId] = useState(null); + const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); - - useEffect(() => { - if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { - setExitingItemId(null); - } - }, [exitingItemId, items]); + const exitingItemId = + requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId) + ? requestedExitingItemId + : null; useEffect(() => { return () => { diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 5786bab478b..64c3acc7bf7 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -8,7 +8,7 @@ interface ComposerPendingApprovalActionsProps { onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; } export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx deleted file mode 100644 index a5aa6e224e8..00000000000 --- a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import "../../index.css"; - -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; -import { render } from "vitest-browser-react"; - -import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; - -describe("ComposerPendingReviewComments", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("renders a removable file comment pill", async () => { - const onRemove = vi.fn(); - const screen = await render( - , - ); - - await expect.element(page.getByText("src/app.ts L2 to L3")).toBeVisible(); - await page.getByRole("button", { name: "Remove comment on src/app.ts L2 to L3" }).click(); - expect(onRemove).toHaveBeenCalledWith("comment-1"); - - await screen.unmount(); - }); -}); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 10031b48cde..fd14c68b0c4 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -9,24 +9,14 @@ interface ExpandedImageDialogProps { } export const ExpandedImageDialog = memo(function ExpandedImageDialog({ - preview: initialPreview, + preview, onClose, }: ExpandedImageDialogProps) { - const [preview, setPreview] = useState(initialPreview); - - // Sync when the parent hands us a new preview reference. - useEffect(() => { - setPreview(initialPreview); - }, [initialPreview]); + const [imageOffset, setImageOffset] = useState(0); + const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; const navigateImage = useCallback((direction: -1 | 1) => { - setPreview((existing) => { - if (existing.images.length <= 1) return existing; - const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; - if (nextIndex === existing.index) return existing; - return { ...existing, index: nextIndex }; - }); + setImageOffset((current) => current + direction); }, []); useEffect(() => { @@ -53,7 +43,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); - const item = preview.images[preview.index]; + const item = preview.images[index]; if (!item) return null; return ( @@ -100,7 +90,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ />

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

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

{title}

-

{subtitle}

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

- {children} - -

- ); -} - -export function DesktopClerkAlert({ children }: { children?: ReactNode }) { - if (!children) return null; - - return ( -
- {children} -
- ); -} - -export function DesktopClerkInput({ - className, - ...props -}: React.ComponentPropsWithoutRef<"input">) { - return ( - - ); -} - -export function DesktopClerkPrimaryButton({ - children, - disabled, -}: { - children: ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - -function DesktopClerkBranding() { - const isDevelopmentMode = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_"); - - return ( -
- - Secured by{" "} - - clerk - - - {isDevelopmentMode ? ( - Development mode - ) : null} -
- ); -} diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx deleted file mode 100644 index a5dc52053ec..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import "../../index.css"; - -import { page, userEvent } from "vite-plus/test/browser"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth"; -import { DesktopClerkSignInCard } from "./DesktopClerkSignIn"; - -const GOOGLE: DesktopCloudAuthOAuthOption = { - strategy: "oauth_google", - label: "Google", - providerId: "google", - iconUrl: null, -}; - -const PROVIDERS: readonly DesktopCloudAuthOAuthOption[] = [ - { - strategy: "oauth_apple", - label: "Apple", - providerId: "apple", - iconUrl: null, - }, - GOOGLE, - { - strategy: "oauth_microsoft", - label: "Microsoft", - providerId: "microsoft", - iconUrl: null, - }, -]; - -describe("DesktopClerkSignInCard", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("uses Clerk's compact provider grid when more than two providers are enabled", async () => { - await render( - , - ); - - expect(document.querySelectorAll('button[aria-label^="Continue with "]')).toHaveLength(3); - expect(document.body.textContent).toContain("Want early access?"); - expect(document.body.textContent).not.toContain("Continue with Google"); - }); - - it("renders a full provider label and starts OAuth for a single provider", async () => { - const onStartOAuth = vi.fn(); - await render( - , - ); - - await userEvent.click(page.getByRole("button", { name: "Continue with Google" })); - - expect(document.body.textContent).toContain("Continue with Google"); - expect(onStartOAuth).toHaveBeenCalledWith("oauth_google"); - }); -}); diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.tsx deleted file mode 100644 index dc8b432e1c7..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkSignIn.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { LoaderCircleIcon } from "lucide-react"; - -import type { - DesktopCloudAuthOAuthOption, - DesktopCloudAuthOAuthStrategy, -} from "../../cloud/desktopAuth"; -import { cn } from "../../lib/utils"; -import { - DesktopClerkAlert, - DesktopClerkCard, - DesktopClerkFooterAction, - DesktopClerkHeader, -} from "./DesktopClerkCard"; -import { useDesktopClerkSignIn } from "./useDesktopClerkSignIn"; - -// Mirrors Clerk's compact social-button layout while delegating OAuth to the desktop bridge: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/SocialButtons.tsx -export function DesktopClerkSignIn({ onJoinWaitlist }: { onJoinWaitlist: () => void }) { - const { isStarting, oauthOptions, startingStrategy, startOAuth } = useDesktopClerkSignIn(); - - return ( - void startOAuth(strategy)} - /> - ); -} - -export function DesktopClerkSignInCard({ - isStarting, - oauthOptions, - startingStrategy, - onJoinWaitlist, - onStartOAuth, -}: { - isStarting: boolean; - oauthOptions: readonly DesktopCloudAuthOAuthOption[]; - startingStrategy: DesktopCloudAuthOAuthStrategy | null; - onJoinWaitlist: () => void; - onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; -}) { - return ( - - Want early access? - - } - > - - {oauthOptions.length === 0 ? ( - No OAuth providers are enabled for desktop sign-in. - ) : ( - - )} - - ); -} - -function DesktopClerkSocialButtons({ - isStarting, - oauthOptions, - startingStrategy, - onStartOAuth, -}: { - isStarting: boolean; - oauthOptions: readonly DesktopCloudAuthOAuthOption[]; - startingStrategy: DesktopCloudAuthOAuthStrategy | null; - onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; -}) { - const useBlockButtons = oauthOptions.length <= 2; - - return ( -
- {oauthOptions.map((option) => { - const isCurrent = option.strategy === startingStrategy; - return ( - - ); - })} -
- ); -} - -function DesktopClerkProviderIcon({ option }: { option: DesktopCloudAuthOAuthOption }) { - if (!option.iconUrl) { - return ( - - {option.label.slice(0, 1).toUpperCase()} - - ); - } - - if (["apple", "github", "vercel"].includes(option.providerId)) { - return ( - - ); - } - - return ; -} diff --git a/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx b/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx deleted file mode 100644 index ec9198498df..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useClerk } from "@clerk/react"; -import { useState } from "react"; - -import { - DesktopClerkAlert, - DesktopClerkCard, - DesktopClerkFooterAction, - DesktopClerkHeader, - DesktopClerkInput, - DesktopClerkPrimaryButton, -} from "./DesktopClerkCard"; -import { DesktopClerkSignIn } from "./DesktopClerkSignIn"; - -type DesktopClerkScreen = "waitlist" | "sign-in"; - -// Mirrors Clerk's waitlist card and form, replacing its router transition with the desktop sign-in flow: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/index.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/WaitlistForm.tsx -export function DesktopClerkWaitlist() { - const [screen, setScreen] = useState("waitlist"); - - if (screen === "sign-in") { - return setScreen("waitlist")} />; - } - - return setScreen("sign-in")} />; -} - -function DesktopClerkWaitlistForm({ onSignIn }: { onSignIn: () => void }) { - const clerk = useClerk(); - const [emailAddress, setEmailAddress] = useState(""); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [didJoin, setDidJoin] = useState(false); - - const submitWaitlist = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - setIsSubmitting(true); - try { - await clerk.joinWaitlist({ emailAddress }); - setDidJoin(true); - } catch (cause) { - setError(getClerkErrorMessage(cause)); - } finally { - setIsSubmitting(false); - } - }; - - if (didJoin) { - return ( - - - - ); - } - - return ( - - Already have access? - - } - > - - {error} -
- - - {isSubmitting ? "Joining the waitlist…" : "Join the waitlist"} - -
-
- ); -} - -function getClerkErrorMessage(error: unknown): string { - if (typeof error === "object" && error !== null && "errors" in error) { - const errors = (error as { errors?: Array<{ longMessage?: unknown; message?: unknown }> }) - .errors; - const firstError = errors?.[0]; - if (typeof firstError?.longMessage === "string") return firstError.longMessage; - if (typeof firstError?.message === "string") return firstError.message; - } - if (error instanceof Error && error.message) return error.message; - return "Could not join the waitlist. Please try again."; -} diff --git a/apps/web/src/components/clerk/useDesktopClerkSignIn.ts b/apps/web/src/components/clerk/useDesktopClerkSignIn.ts deleted file mode 100644 index 7b58c4f1ee6..00000000000 --- a/apps/web/src/components/clerk/useDesktopClerkSignIn.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { useClerk } from "@clerk/react"; -import { useSignIn, useSignUp } from "@clerk/react/legacy"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { - type DesktopCloudAuthOAuthStrategy, - resolveDesktopCloudAuthOAuthOptions, -} from "../../cloud/desktopAuth"; -import { toastManager } from "../ui/toast"; - -// Mirrors Clerk Expo's browser-based native SSO flow, with Electron handling the external browser -// and callback transport: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/hooks/useSSO.ts -class DesktopClerkOperationError extends Error { - override readonly cause?: unknown; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = "DesktopClerkOperationError"; - this.cause = cause; - } -} - -async function runDesktopClerkOperation( - operation: () => Promise, - message: string, -): Promise { - try { - return await operation(); - } catch (cause) { - throw new DesktopClerkOperationError(message, cause); - } -} - -function desktopClerkErrorMessage(error: unknown, fallback: string): string { - if (error instanceof DesktopClerkOperationError) { - const cause = error.cause; - if (cause instanceof Error && cause.message && cause.message !== error.message) { - return `${error.message}: ${cause.message}`; - } - return error.message; - } - return error instanceof Error ? error.message : fallback; -} - -export function useDesktopClerkSignIn() { - const clerk = useClerk(); - const { setActive } = clerk; - const { isLoaded: signInLoaded, signIn } = useSignIn(); - const { isLoaded: signUpLoaded, signUp } = useSignUp(); - const [startingStrategy, setStartingStrategy] = useState( - null, - ); - const oauthOptions = resolveDesktopCloudAuthOAuthOptions(clerk); - const callbackCleanupRef = useRef<(() => void) | null>(null); - - const clearCallbackListener = useCallback(() => { - callbackCleanupRef.current?.(); - callbackCleanupRef.current = null; - }, []); - - const completeOAuthCallback = useCallback( - async (rawUrl: string) => { - if (!signInLoaded || !signIn || !signUpLoaded || !signUp) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: "Clerk is still loading. Try signing in again.", - }); - return; - } - - let rotatingTokenNonce: string | null = null; - let sessionId: string | null = null; - try { - const callbackUrl = new URL(rawUrl); - rotatingTokenNonce = callbackUrl.searchParams.get("rotating_token_nonce"); - sessionId = callbackUrl.searchParams.get("created_session_id"); - } catch { - // Handled by the explicit nonce check below. - } - if (!rotatingTokenNonce) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: - "Clerk did not return a native session nonce. Verify this redirect URL is allowlisted for native SSO redirects.", - }); - return; - } - - try { - await runDesktopClerkOperation( - () => signIn.reload({ rotatingTokenNonce }), - "Could not reload the desktop sign-in session.", - ); - sessionId = sessionId || signIn.createdSessionId; - - if (!sessionId && signIn.firstFactorVerification.status === "transferable") { - const signUpAttempt = await runDesktopClerkOperation( - () => signUp.create({ transfer: true }), - "Could not transfer the desktop sign-up session.", - ); - sessionId = signUpAttempt.createdSessionId; - } - - if (!sessionId) { - throw new DesktopClerkOperationError("Clerk did not create a desktop session."); - } - - await runDesktopClerkOperation( - () => setActive({ session: sessionId! }), - "Could not activate the desktop cloud session.", - ); - } catch (error) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: desktopClerkErrorMessage(error, "Could not complete cloud sign-in."), - }); - } - }, - [setActive, signIn, signInLoaded, signUp, signUpLoaded], - ); - - useEffect(() => { - return () => { - clearCallbackListener(); - }; - }, [clearCallbackListener]); - - const startOAuth = useCallback( - async (strategy: DesktopCloudAuthOAuthStrategy) => { - if (!signInLoaded || !signIn) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: "Clerk is still loading. Try signing in again.", - }); - return; - } - - setStartingStrategy(strategy); - clearCallbackListener(); - try { - const redirectUrl = await runDesktopClerkOperation( - () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), - "Desktop auth callback is unavailable.", - ); - if (!redirectUrl) { - throw new DesktopClerkOperationError("Desktop auth callback is unavailable."); - } - - callbackCleanupRef.current = - window.desktopBridge?.onCloudAuthCallback((rawUrl) => { - clearCallbackListener(); - void completeOAuthCallback(rawUrl); - }) ?? null; - - await runDesktopClerkOperation( - () => signIn.create({ strategy, redirectUrl } as never), - "Could not create the desktop OAuth request.", - ); - const externalUrl = - signIn.firstFactorVerification.externalVerificationRedirectURL?.toString(); - if (!externalUrl) { - throw new DesktopClerkOperationError( - "Clerk did not return an external OAuth redirect URL.", - ); - } - - const opened = await runDesktopClerkOperation( - () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), - "Could not open the system browser.", - ); - if (!opened) { - throw new DesktopClerkOperationError("Could not open the system browser."); - } - } catch (error) { - clearCallbackListener(); - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: desktopClerkErrorMessage(error, "Could not start cloud sign-in."), - }); - } finally { - setStartingStrategy(null); - } - }, - [clearCallbackListener, completeOAuthCallback, signIn, signInLoaded], - ); - - return { - isStarting: startingStrategy !== null, - oauthOptions, - startingStrategy, - startOAuth, - }; -} diff --git a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx index b38d630dfa3..05fa8250b30 100644 --- a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx +++ b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx @@ -1,29 +1,9 @@ import { useClerk } from "@clerk/react"; -import { useState } from "react"; - -import { isElectron } from "../../env"; -import { Dialog, DialogPopup } from "../ui/dialog"; -import { DesktopClerkWaitlist } from "./DesktopClerkWaitlist"; export function useT3ConnectAuthPrompt() { const clerk = useClerk(); - const [desktopAuthOpen, setDesktopAuthOpen] = useState(false); - const openAuthPrompt = () => { - if (isElectron) { - setDesktopAuthOpen(true); - return; - } clerk.openWaitlist(); }; - - const authPrompt = isElectron ? ( - - - - - - ) : null; - - return { authPrompt, openAuthPrompt }; + return { authPrompt: null, openAuthPrompt }; } diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx deleted file mode 100644 index b4bb763593f..00000000000 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import "../../index.css"; - -import { page } from "vite-plus/test/browser"; -import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { - finishRelayClientInstall, - readRelayClientInstallDialogState, - reportRelayClientInstallProgress, - requestRelayClientInstallConfirmation, - resetRelayClientInstallDialogForTests, -} from "../../cloud/relayClientInstallDialog"; -import { RelayClientInstallDialog } from "./RelayClientInstallDialog"; - -describe("RelayClientInstallDialog", () => { - beforeEach(() => { - resetRelayClientInstallDialogForTests(); - }); - - it("confirms installation and renders streamed progress", async () => { - render(); - const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); - - await expect.element(page.getByText("Install relay client?")).toBeInTheDocument(); - await expect.element(page.getByText(/version 2026\.5\.2 locally/)).toBeInTheDocument(); - - await page.getByRole("button", { name: "Download and install" }).click(); - await expect(confirmation).resolves.toBe(true); - await expect - .element(page.getByRole("heading", { name: "Installing relay client" })) - .toBeInTheDocument(); - - reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); - await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); - await expect - .element(page.getByRole("progressbar", { name: "Relay client installation progress" })) - .toHaveAttribute("value", "3"); - - finishRelayClientInstall(); - expect(readRelayClientInstallDialogState().status).toBe("closing"); - await expect - .element(page.getByRole("heading", { name: "Installing relay client" })) - .not.toBeInTheDocument(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); -}); diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index 7a20badf02b..a6ff9d8c4ec 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -30,14 +30,7 @@ function getPromptErrorMessage(error: unknown): string { export function SshPasswordPromptDialog() { const [queue, setQueue] = useState([]); - const [password, setPassword] = useState(""); - const [isResponding, setIsResponding] = useState(false); - const [now, setNow] = useState(() => Date.now()); - const [responseError, setResponseError] = useState(null); const currentRequest = queue[0] ?? null; - const inputRef = useRef(null); - const isRespondingRef = useRef(false); - const formId = useId(); useEffect(() => { const bridge = window.desktopBridge; @@ -50,14 +43,39 @@ export function SshPasswordPromptDialog() { }); }, []); - useEffect(() => { - setPassword(""); - setResponseError(null); - if (!currentRequest) { - return; - } + if (!currentRequest) { + return null; + } + + return ( + { + setQueue((currentQueue) => + currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, + ); + }} + /> + ); +} + +function ActiveSshPasswordPrompt({ + request, + onRemove, +}: { + readonly request: DesktopSshPasswordPromptRequest; + readonly onRemove: (requestId: string) => void; +}) { + const [password, setPassword] = useState(""); + const [isResponding, setIsResponding] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [responseError, setResponseError] = useState(null); + const inputRef = useRef(null); + const isRespondingRef = useRef(false); + const formId = useId(); - setNow(Date.now()); + useEffect(() => { const frame = window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); @@ -65,48 +83,33 @@ export function SshPasswordPromptDialog() { return () => { window.cancelAnimationFrame(frame); }; - }, [currentRequest]); + }, []); useEffect(() => { - if (!currentRequest) { - return; - } - const interval = window.setInterval(() => { setNow(Date.now()); }, 1_000); return () => { window.clearInterval(interval); }; - }, [currentRequest]); + }, []); - const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; + const expiresAtMs = Date.parse(request.expiresAt); const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; const isExpired = remainingMs !== null && remainingMs <= 0; const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); const remainingLabel = remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); - - useEffect(() => { - if (isExpired) { - setResponseError("This SSH password prompt expired. Try connecting again."); - } - }, [isExpired]); - - const removeCurrentPrompt = (requestId: string) => { - setQueue((currentQueue) => - currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, - ); - setPassword(""); - setResponseError(null); - }; + const visibleResponseError = isExpired + ? "This SSH password prompt expired. Try connecting again." + : responseError; const respond = async (nextPassword: string | null) => { - if (!currentRequest || isRespondingRef.current) { + if (isRespondingRef.current) { return; } - const requestId = currentRequest.requestId; + const requestId = request.requestId; if (nextPassword !== null && isExpired) { setResponseError("This SSH password prompt expired. Try connecting again."); return; @@ -117,10 +120,10 @@ export function SshPasswordPromptDialog() { setResponseError(null); try { await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); - removeCurrentPrompt(requestId); + onRemove(requestId); } catch (error) { if (nextPassword === null) { - removeCurrentPrompt(requestId); + onRemove(requestId); } else { setResponseError(getPromptErrorMessage(error)); } @@ -131,9 +134,7 @@ export function SshPasswordPromptDialog() { }; const dismissExpiredPrompt = () => { - if (currentRequest) { - removeCurrentPrompt(currentRequest.requestId); - } + onRemove(request.requestId); }; const cancelPrompt = () => { @@ -144,11 +145,11 @@ export function SshPasswordPromptDialog() { void respond(null); }; - const target = currentRequest ? describeSshTarget(currentRequest) : null; + const target = describeSshTarget(request); return ( { if (!open) { cancelPrompt(); @@ -159,9 +160,8 @@ export function SshPasswordPromptDialog() { SSH Password Required - T3 needs your SSH password to connect to{" "} - {target ? {target} : "the remote host"}. The password is passed to the - local SSH process for this connection attempt and is not saved by T3 Code. + T3 needs your SSH password to connect to {target}. The password is passed + to the local SSH process for this connection attempt and is not saved by T3 Code. @@ -175,7 +175,7 @@ export function SshPasswordPromptDialog() { >
-

{currentRequest?.prompt}

+

{request.prompt}

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

{responseError}

+ {visibleResponseError ? ( +

{visibleResponseError}

) : (

Use SSH keys to avoid repeated password prompts on new SSH sessions. diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx deleted file mode 100644 index 393c0ab1634..00000000000 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import "../../index.css"; - -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; -import { render } from "vitest-browser-react"; - -import { useComposerDraftStore } from "~/composerDraftStore"; - -import { AnnotatableFileDiff } from "./AnnotatableFileDiff"; - -function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { - target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - cancelable: true, - composed: true, - pointerId, - pointerType: "mouse", - }), - ); -} - -const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-1")); - -function TestDiff() { - const fileDiff = parsePatchFiles( - [ - "diff --git a/src/app.ts b/src/app.ts", - "--- a/src/app.ts", - "+++ b/src/app.ts", - "@@ -1,3 +1,3 @@", - " one", - "-two", - "+TWO", - " three", - ].join("\n"), - "annotatable-file-diff-test", - )[0]!.files[0]!; - - return ( - null} - options={{ - diffStyle: "unified", - lineDiffType: "none", - themeType: "light", - }} - /> - ); -} - -async function getRenderedDiff() { - return vi.waitFor(() => { - const element = document.querySelector("diffs-container"); - expect(element?.shadowRoot).not.toBeNull(); - return element!; - }); -} - -describe("annotatable Pierre file diff", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.getState().setReviewComments(threadRef, []); - }); - - it("creates a local annotation from the gutter and attaches it to the composer", async () => { - let screen = await render(); - - try { - const diff = await getRenderedDiff(); - const addedLineNumber = await vi.waitFor(() => { - const elements = Array.from( - diff.shadowRoot?.querySelectorAll('[data-column-number="2"]') ?? [], - ); - const element = elements.at(-1) ?? null; - expect(element).not.toBeNull(); - return element!; - }); - - dispatchPointer(addedLineNumber, "pointerdown", 1); - dispatchPointer(addedLineNumber, "pointerup", 1); - - const textarea = page.getByRole("textbox", { name: "Comment on lines +2" }); - await expect.element(textarea).toBeVisible(); - await textarea.fill("Use the compatible value."); - await page.getByRole("button", { name: "Comment" }).click(); - - await vi.waitFor(() => { - expect( - useComposerDraftStore.getState().getComposerDraft(threadRef)?.reviewComments, - ).toEqual([ - expect.objectContaining({ - sectionId: "turn:2", - filePath: "src/app.ts", - rangeLabel: "+2", - text: "Use the compatible value.", - diff: "@@ -0,0 +2,1 @@\n+TWO", - }), - ]); - }); - expect(document.querySelector("[data-file-comment-annotation]")?.textContent).toContain( - "Use the compatible value.", - ); - - await screen.unmount(); - screen = await render(); - await expect - .element(page.getByText("Use the compatible value.", { exact: true })) - .toBeVisible(); - } finally { - await screen.unmount(); - } - }); -}); diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx index ceb2f87785a..f74b1e59aa3 100644 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx @@ -1,14 +1,23 @@ import type { AnnotationSide, + CodeViewDiffItem, + CodeViewItem, DiffLineAnnotation, FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { FileDiff, type FileDiffProps } from "@pierre/diffs/react"; +import { + CodeView, + type CodeViewHandle, + type CodeViewProps, + FileDiff, + type FileDiffProps, +} from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { fnv1a32 } from "~/lib/diffRendering"; import { buildDiffReviewComment, restoreDiffReviewCommentRange, @@ -31,6 +40,7 @@ interface DiffCommentAnnotationGroup { } type DiffCommentLineAnnotation = DiffLineAnnotation; +export type AnnotatableCodeViewHandle = CodeViewHandle; const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; function annotationSide(range: SelectedLineRange): AnnotationSide { @@ -237,3 +247,200 @@ export function AnnotatableFileDiff({ /> ); } + +interface AnnotatableCodeViewProps { + files: ReadonlyArray<{ + fileDiff: FileDiffMetadata; + filePath: string; + fileKey: string; + collapsed: boolean; + }>; + sectionId: string; + sectionTitle: string; + composerDraftTarget: ScopedThreadRef | DraftId; + options: NonNullable["options"]>; + viewerRef?: Ref; + className?: string; + renderHeaderPrefix: ( + fileDiff: FileDiffMetadata, + fileKey: string, + collapsed: boolean, + ) => ReactNode; +} + +interface DiffSelectionContext { + item: CodeViewItem; +} + +export function AnnotatableCodeView({ + files, + sectionId, + sectionTitle, + composerDraftTarget, + options, + viewerRef, + className, + renderHeaderPrefix, +}: AnnotatableCodeViewProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const reviewComments = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, + ); + const [selectedLines, setSelectedLines] = useState<{ + id: string; + range: SelectedLineRange; + } | null>(null); + const [draft, setDraft] = useState<{ + fileKey: string; + annotation: DiffCommentLineAnnotation; + } | null>(null); + + const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]); + const items = useMemo[]>( + () => + files.map(({ fileDiff, filePath, fileKey, collapsed }) => { + const persisted = reviewComments + .filter( + (comment) => + comment.sectionId === sectionId && + comment.filePath === filePath && + (comment.fenceLanguage ?? "diff") === "diff", + ) + .reduce((annotations, comment) => { + const range = restoreDiffReviewCommentRange(fileDiff, comment); + if (!range) return annotations; + return appendAnnotationEntry(annotations, range, { + id: comment.id, + kind: "comment", + range, + rangeLabel: comment.rangeLabel, + text: comment.text, + }); + }, []); + const annotations = + draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; + return { + id: fileKey, + type: "diff", + fileDiff, + annotations, + collapsed, + version: fnv1a32( + `${collapsed ? "1" : "0"}:${annotations + .flatMap((annotation) => + annotation.metadata.entries.map( + (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, + ), + ) + .join(":")}`, + ), + }; + }), + [draft, files, reviewComments, sectionId], + ); + + const removeEntry = useCallback( + (entryId: string) => { + setSelectedLines(null); + if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) { + setDraft(null); + } else { + removeReviewComment(composerDraftTarget, entryId); + } + }, + [composerDraftTarget, draft, removeReviewComment], + ); + + const submitEntry = useCallback( + (entryId: string, text: string) => { + const entry = draft?.annotation.metadata.entries.find( + (candidate) => candidate.id === entryId, + ); + const file = draft ? filesByKey.get(draft.fileKey) : undefined; + if (!entry || !file) return; + const comment = buildDiffReviewComment({ + id: entry.id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range: entry.range, + text, + }); + if (comment) addReviewComment(composerDraftTarget, comment); + setSelectedLines(null); + setDraft(null); + }, + [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle], + ); + + const beginComment = useCallback( + (range: SelectedLineRange | null, context: DiffSelectionContext) => { + if (!range) return; + const item = context.item; + if (item.type !== "diff") return; + const file = filesByKey.get(item.id); + if (!file) return; + const id = nextFileCommentId(); + const comment = buildDiffReviewComment({ + id, + sectionId, + sectionTitle, + filePath: file.filePath, + fileDiff: file.fileDiff, + range, + text: "", + }); + if (!comment) return; + setDraft({ + fileKey: item.id, + annotation: { + side: annotationSide(range), + lineNumber: range.end, + metadata: { + entries: [{ id, kind: "draft", range, rangeLabel: comment.rangeLabel, text: "" }], + }, + }, + }); + }, + [filesByKey, sectionId, sectionTitle], + ); + + const hasOpenComment = draft !== null; + return ( + + {...(viewerRef ? { ref: viewerRef } : {})} + {...(className ? { className } : {})} + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={{ + ...options, + enableGutterUtility: !hasOpenComment, + enableLineSelection: !hasOpenComment, + onLineSelectionEnd: beginComment, + }} + renderHeaderPrefix={(item) => + item.type === "diff" + ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true) + : null + } + renderAnnotation={(annotation) => ( +

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

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

{shareablePairingUrl === null ? (

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

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

+ {endpoint.httpBaseUrl} +

) : null} {!isAvailable ? ( @@ -1471,54 +1397,42 @@ function NetworkAccessDescription({ } type SavedBackendListRowProps = { - environmentId: EnvironmentId; - reconnectingEnvironmentId: EnvironmentId | null; - disconnectingEnvironmentId: EnvironmentId | null; + environment: EnvironmentPresentation; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; - onDisconnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ - environmentId, - reconnectingEnvironmentId, - disconnectingEnvironmentId, + environment, removingEnvironmentId, onConnect, - onDisconnect, onRemove, }: SavedBackendListRowProps) { - const nowMs = useRelativeTimeTick(1_000); - const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); - const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); - - if (!record) { - return null; - } - - const connectionState = runtime?.connectionState ?? "disconnected"; + const environmentId = environment.environmentId; + const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; - const isConnecting = - connectionState === "connecting" || reconnectingEnvironmentId === environmentId; - const isDisconnecting = disconnectingEnvironmentId === environmentId; + const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; const stateDotClassName = connectionState === "connected" ? "bg-success" - : connectionState === "connecting" + : connectionState === "connecting" || connectionState === "reconnecting" ? "bg-warning" : connectionState === "error" ? "bg-destructive" : "bg-muted-foreground/40"; - const descriptorLabel = runtime?.descriptor?.label ?? null; - const displayLabel = descriptorLabel ?? record.label; - const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); - const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); + const statusTooltip = connectionStatusText(environment.connection); + const errorTraceId = environment.connection.traceId; + const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const sshTarget = + environment.entry.target._tag === "SshConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "SshConnectionProfile" + ? environment.entry.profile.value.target + : null; const metadataBits = [ - record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, - record.lastConnectedAt - ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` - : null, + sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.relayManaged ? "T3 Cloud" : null, ].filter((value): value is string => value !== null); return ( @@ -1530,19 +1444,15 @@ function SavedBackendListRow({ tooltipText={statusTooltip} dotClassName={stateDotClassName} pingClassName={ - connectionState === "connecting" ? "bg-warning/60 duration-2000" : null + connectionState === "connecting" || connectionState === "reconnecting" + ? "bg-warning/60 duration-2000" + : null } /> -

{displayLabel}

+

{environment.label}

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

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

+ {metadataBits.length > 0 ? ( +

{metadataBits.join(" · ")}

) : null} {versionMismatch ? (

@@ -1551,32 +1461,36 @@ function SavedBackendListRow({ {versionMismatch.serverVersion}.

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

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

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

{environment.label}

-

T3 Connect

+

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