From ae2b523503cc4b28d0994847cc35fed4e183c082 Mon Sep 17 00:00:00 2001 From: Ryan Skinner Date: Thu, 18 Jun 2026 07:33:01 +0200 Subject: [PATCH 001/142] fix(mobile): bump react-native-shiki-engine to 0.3.12 (#3120) --- apps/mobile/package.json | 12 +- ...tch => @pierre%2Fdiffs@1.3.0-beta.5.patch} | 20 +- pnpm-lock.yaml | 690 +++++++++--------- pnpm-workspace.yaml | 5 +- 4 files changed, 372 insertions(+), 355 deletions(-) rename patches/{@pierre%2Fdiffs@1.3.0-beta.4.patch => @pierre%2Fdiffs@1.3.0-beta.5.patch} (79%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 47efc95adb1..f0a4dc2a905 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", @@ -98,10 +98,10 @@ "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-worklets": "0.8.3", - "shiki": "3.23.0", + "shiki": "4.2.0", "tailwind-merge": "^3.5.0", "uniwind": "^1.6.2" }, diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.4.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch similarity index 79% rename from patches/@pierre%2Fdiffs@1.3.0-beta.4.patch rename to patches/@pierre%2Fdiffs@1.3.0-beta.5.patch index 9cecb1b654f..59aa02f6d1c 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.4.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch @@ -1,28 +1,26 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index 4346c77a4e43a98c8524ea5186559fdee7433174..bb2a7ecd1d7f497916a5f220215d5191b095575b 100644 +index e8013fc6eb6f243a6c912facf3fc0319ac66a8d0..80c82df4cdeb828bd331f5ec2f443d216bedc304 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -77,16 +77,13 @@ var Editor = class { +@@ -77,15 +77,12 @@ var Editor = class { this.#options = options; } edit(component) { - const { useTokenTransformer, enableGutterUtility, enableLineSelection, expandUnchanged, diffStyle, lineHoverHighlight,...rest } = component.options; -- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || expandUnchanged !== true && Object.hasOwn(component, "fileDiff") || diffStyle === "unified" || lineHoverHighlight !== "disabled") { + const { useTokenTransformer, expandUnchanged, diffStyle,...rest } = component.options; -+ if (useTokenTransformer !== true || expandUnchanged !== true && Object.hasOwn(component, "fileDiff") || diffStyle === "unified") { + const isDiff = component.type === "file-diff"; +- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled" || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { ++ if (useTokenTransformer !== true || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { component.setOptions({ ...rest, useTokenTransformer: true, - enableGutterUtility: false, - enableLineSelection: false, +- lineHoverHighlight: "disabled", expandUnchanged: true, -- diffStyle: "split", -- lineHoverHighlight: "disabled" -+ diffStyle: "split" + diffStyle: "split" }); - component.rerender(); - } -@@ -481,6 +478,7 @@ var Editor = class { +@@ -511,6 +508,7 @@ var Editor = class { return lineNumber - 1; }; this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => { @@ -47,7 +45,7 @@ index cb8e2026fb5d7a19f489c0a2402efbcb7dff3322..510fad6364d4a2214c7dd65fe2b114f1 return merged; } diff --git a/package.json b/package.json -index cff9c6b2a955e7568f44279a5b706da164c4f142..3d1f25f8bd1be682549677718d9ed8433872c854 100644 +index d1558633de87044b7aa96cff09443db11f163cec..c0b16f0a0bec6fba2026f24f38b2c0a8fa06af7c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad811a254d4..ce5f5ec66ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ catalogs: specifier: 1.8.0 version: 1.8.0 '@pierre/diffs': - specifier: 1.3.0-beta.4 - version: 1.3.0-beta.4 + specifier: 1.3.0-beta.5 + version: 1.3.0-beta.5 '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 @@ -51,6 +51,7 @@ overrides: '@effect/vitest>@vitest/runner': '-' '@effect/vitest>vitest': '-' '@expo/metro-config': 56.0.13 + '@pierre/diffs>@shikijs/transformers': ^4.2.0 '@types/node': 24.12.4 effect: 4.0.0-beta.78 vite: npm:@voidzero-dev/vite-plus-core@0.1.24 @@ -69,9 +70,9 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 path: patches/@ff-labs__fff-node@0.9.4.patch - '@pierre/diffs@1.3.0-beta.4': - hash: 0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631 - path: patches/@pierre%2Fdiffs@1.3.0-beta.4.patch + '@pierre/diffs@1.3.0-beta.5': + hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a + path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch effect@4.0.0-beta.78: hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 path: patches/effect@4.0.0-beta.78.patch @@ -177,10 +178,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: ^3.4.1 - version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -189,10 +190,10 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -201,22 +202,22 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': - specifier: 3.23.0 - version: 3.23.0 + specifier: 4.2.0 + version: 4.2.0 '@shikijs/engine-javascript': - specifier: 3.23.0 - version: 3.23.0 + specifier: 4.2.0 + version: 4.2.0 '@shikijs/langs': - specifier: 3.23.0 - version: 3.23.0 + specifier: 4.2.0 + version: 4.2.0 '@shikijs/themes': - specifier: 3.23.0 - version: 3.23.0 + specifier: 4.2.0 + version: 4.2.0 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -225,7 +226,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -246,40 +247,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.15 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -288,16 +289,16 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-notifications: specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + version: 56.2.8(c021de11d02907bd585610408f5252e8) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -306,16 +307,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.15 - version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -327,49 +328,49 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: ^0.35.4 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: - specifier: ^0.3.9 - version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ^0.3.12 + version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: - specifier: 3.23.0 - version: 3.23.0 + specifier: 4.2.0 + version: 4.2.0 tailwind-merge: specifier: ^3.5.0 version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -415,7 +416,7 @@ importers: version: 1.15.13 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) @@ -497,7 +498,7 @@ importers: version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3269,8 +3270,8 @@ packages: resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@pierre/diffs@1.3.0-beta.4': - resolution: {integrity: sha512-poFcsvhcQt9lH/InzAPaGs47WYHMidnFCjuYGNU41HiVLJP4mkIQDSdvcnPIkBuh/cYbPOQg/YE3T1kSpr01GA==} + '@pierre/diffs@1.3.0-beta.5': + resolution: {integrity: sha512-d7449IY6Phcg9LCRLbPxhsxn6Bv4KoaP/vPyZtGu2uR1SFsSJPQcRoPf8lzyobNGKD0GZGuhgHW5LrOlilFo7w==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -4055,55 +4056,66 @@ packages: cpu: [x64] os: [win32] - '@shikijs/core@3.23.0': - resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} - '@shikijs/core@4.1.0': resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} engines: {node: '>=20'} - '@shikijs/engine-javascript@3.23.0': - resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/core@4.2.0': + resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==} + engines: {node: '>=20'} '@shikijs/engine-javascript@4.1.0': resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@3.23.0': - resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/engine-javascript@4.2.0': + resolution: {integrity: sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==} + engines: {node: '>=20'} '@shikijs/engine-oniguruma@4.1.0': resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} engines: {node: '>=20'} - '@shikijs/langs@3.23.0': - resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/engine-oniguruma@4.2.0': + resolution: {integrity: sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==} + engines: {node: '>=20'} '@shikijs/langs@4.1.0': resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} engines: {node: '>=20'} + '@shikijs/langs@4.2.0': + resolution: {integrity: sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==} + engines: {node: '>=20'} + '@shikijs/primitive@4.1.0': resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} engines: {node: '>=20'} - '@shikijs/themes@3.23.0': - resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/primitive@4.2.0': + resolution: {integrity: sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==} + engines: {node: '>=20'} '@shikijs/themes@4.1.0': resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} engines: {node: '>=20'} - '@shikijs/transformers@3.23.0': - resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + '@shikijs/themes@4.2.0': + resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==} + engines: {node: '>=20'} - '@shikijs/types@3.23.0': - resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/transformers@4.2.0': + resolution: {integrity: sha512-pKrYVNUr1oPjJvs76gkPPirDySx3GKG9O88P2Y3AQ+7AjSFws9Y+Ry/Q/6Yg6QpyigzjdQ6H5JAMNAvLXZ63dw==} + engines: {node: '>=20'} '@shikijs/types@4.1.0': resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} engines: {node: '>=20'} + '@shikijs/types@4.2.0': + resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -8483,8 +8495,8 @@ packages: react: '*' react-native: '>=0.82.0' - react-native-shiki-engine@0.3.10: - resolution: {integrity: sha512-gaEFPbd3//nFCApZOj1c+4Q69c1aqFYYn0v22YRW4jRVerf2MmcAWXYnRnmkJIxJ6hXy8U2JsBEAX6HhsCipzA==} + react-native-shiki-engine@0.3.12: + resolution: {integrity: sha512-CE6CA3uHGZT5OmY909H+vTv5lrmILV7zuPOF2pXRYXWN2qYm5KG7XacEB/Pq1U2+8D0zJoeMveTny/FHEqnipg==} peerDependencies: react: '*' react-native: '*' @@ -8880,13 +8892,14 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} - shiki@3.23.0: - resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - shiki@4.1.0: resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} engines: {node: '>=20'} + shiki@4.2.0: + resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==} + engines: {node: '>=20'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -10020,7 +10033,7 @@ snapshots: js-yaml: 4.2.0 picomatch: 4.0.4 retext-smartypants: 6.2.0 - shiki: 4.1.0 + shiki: 4.2.0 smol-toml: 1.6.1 unified: 11.0.5 @@ -10819,10 +10832,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.0': dependencies: @@ -10894,23 +10907,23 @@ snapshots: - react - react-dom - '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -11477,7 +11490,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -11487,7 +11500,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -11512,7 +11525,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11538,8 +11551,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -11601,18 +11614,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -11673,13 +11686,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -11709,7 +11722,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11727,14 +11740,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -11809,14 +11822,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -11831,18 +11844,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': + '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12103,13 +12116,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -12656,51 +12669,51 @@ snapshots: '@oxlint/plugins@1.68.0': {} - '@pierre/diffs@1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@3.23.0) - '@shikijs/transformers': 3.23.0 + '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) + '@shikijs/transformers': 4.2.0 diff: 8.0.3 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - shiki: 3.23.0 + shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.4(patch_hash=0befe84f4202720eeab6fa684d8761a5c0cc7046b58cf2c0b804ad2baa7ce631)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@3.23.0) - '@shikijs/transformers': 3.23.0 + '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@shikijs/transformers': 4.2.0 diff: 8.0.3 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - shiki: 3.23.0 + shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' '@pierre/theme@1.0.3': {} - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@3.23.0)': + '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 1.0.3 - '@shikijs/themes': 3.23.0 + '@shikijs/themes': 4.2.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - shiki: 3.23.0 + shiki: 4.2.0 - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@3.23.0)': + '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 1.0.3 - '@shikijs/themes': 4.1.0 + '@shikijs/themes': 4.2.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - shiki: 3.23.0 + shiki: 4.2.0 '@pierre/trees@1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -12932,15 +12945,15 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13000,7 +13013,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13010,7 +13023,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.1 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color @@ -13058,7 +13071,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13066,18 +13079,16 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13326,13 +13337,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.0': optional: true - '@shikijs/core@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - '@shikijs/core@4.1.0': dependencies: '@shikijs/primitive': 4.1.0 @@ -13341,11 +13345,13 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.23.0': + '@shikijs/core@4.2.0': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/primitive': 4.2.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 '@shikijs/engine-javascript@4.1.0': dependencies: @@ -13353,49 +13359,61 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@3.23.0': + '@shikijs/engine-javascript@4.2.0': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 '@shikijs/engine-oniguruma@4.1.0': dependencies: '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.23.0': + '@shikijs/engine-oniguruma@4.2.0': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.2.0 + '@shikijs/vscode-textmate': 10.0.2 '@shikijs/langs@4.1.0': dependencies: '@shikijs/types': 4.1.0 + '@shikijs/langs@4.2.0': + dependencies: + '@shikijs/types': 4.2.0 + '@shikijs/primitive@4.1.0': dependencies: '@shikijs/types': 4.1.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/themes@3.23.0': + '@shikijs/primitive@4.2.0': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.2.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 '@shikijs/themes@4.1.0': dependencies: '@shikijs/types': 4.1.0 - '@shikijs/transformers@3.23.0': + '@shikijs/themes@4.2.0': + dependencies: + '@shikijs/types': 4.2.0 + + '@shikijs/transformers@4.2.0': dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/types': 3.23.0 + '@shikijs/core': 4.2.0 + '@shikijs/types': 4.2.0 - '@shikijs/types@3.23.0': + '@shikijs/types@4.1.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/types@4.1.0': + '@shikijs/types@4.2.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -13483,15 +13501,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': dependencies: - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.8) - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14608,8 +14626,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15482,29 +15500,29 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -15512,119 +15530,119 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15637,61 +15655,61 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): + expo-router@56.2.8(c021de11d02907bd585610408f5252e8): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -15699,18 +15717,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -15722,7 +15740,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -15730,7 +15748,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15738,20 +15756,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -15759,7 +15777,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15769,25 +15787,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): + expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -15796,35 +15814,35 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@babel/core' @@ -18223,95 +18241,95 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): + react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.1 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18323,23 +18341,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.1 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -18898,17 +18916,6 @@ snapshots: shell-quote@1.8.4: {} - shiki@3.23.0: - dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/engine-javascript': 3.23.0 - '@shikijs/engine-oniguruma': 3.23.0 - '@shikijs/langs': 3.23.0 - '@shikijs/themes': 3.23.0 - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - shiki@4.1.0: dependencies: '@shikijs/core': 4.1.0 @@ -18920,6 +18927,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@4.2.0: + dependencies: + '@shikijs/core': 4.2.0 + '@shikijs/engine-javascript': 4.2.0 + '@shikijs/engine-oniguruma': 4.2.0 + '@shikijs/langs': 4.2.0 + '@shikijs/themes': 4.2.0 + '@shikijs/types': 4.2.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -19372,14 +19390,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 58f4b6e0dfe..233680b725f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,7 @@ catalog: "@effect/vitest": 4.0.0-beta.78 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.3.0-beta.4 + "@pierre/diffs": 1.3.0-beta.5 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 "@vitest/runner": 4.1.8 @@ -56,6 +56,7 @@ overrides: "@effect/vitest>@vitest/runner": "-" "@effect/vitest>vitest": "-" "@expo/metro-config": 56.0.13 + "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" vite: "catalog:" @@ -77,7 +78,7 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - "@pierre/diffs@1.3.0-beta.4": patches/@pierre%2Fdiffs@1.3.0-beta.4.patch + "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch From b7b00bfb9dde73f1152dccab3b8e25919346700d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:43:47 +0200 Subject: [PATCH 002/142] fix: Adapt destructive menu icon to dark mode (#3126) Co-authored-by: Julius Marminge --- apps/desktop/src/electron/ElectronMenu.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index cb25043ff44..2ffda3dc507 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -99,6 +99,7 @@ export const layer = Layer.effect( width: 12, height: 12, }); + icon.setTemplateImage(true); destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); } catch { destructiveMenuIconCache = Option.none(); From 3bdaa6e10046945ccc08264d2d6f3b81775efcb3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 17 Jun 2026 23:23:14 -0700 Subject: [PATCH 003/142] Polish marketing homepage: nav, hero, and endorsements (#3137) Co-authored-by: Claude Opus 4.8 (1M context) --- apps/marketing/src/layouts/Layout.astro | 58 ++++-- apps/marketing/src/lib/site.ts | 6 + apps/marketing/src/pages/index.astro | 241 +++++++++++++++++------- 3 files changed, 227 insertions(+), 78 deletions(-) create mode 100644 apps/marketing/src/lib/site.ts 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 ────────────────────────────────────────── */ From e95b57dc268471c882f734546fdcd92e9a833e45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 13:10:32 -0700 Subject: [PATCH 004/142] [codex] Rewrite client connection architecture (#2978) Co-authored-by: codex --- .github/workflows/ci.yml | 46 - .../app/DesktopConnectionCatalogStore.test.ts | 297 + .../src/app/DesktopConnectionCatalogStore.ts | 328 + .../src/electron/ElectronSafeStorage.ts | 8 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 18 +- apps/desktop/src/ipc/channels.ts | 8 +- apps/desktop/src/ipc/methods/cloudAuth.ts | 2 +- .../src/ipc/methods/connectionCatalog.ts | 37 + .../src/ipc/methods/savedEnvironments.ts | 76 - .../desktop/src/ipc/methods/sshEnvironment.ts | 4 +- apps/desktop/src/main.ts | 3 +- apps/desktop/src/preload.ts | 14 +- .../settings/DesktopSavedEnvironments.test.ts | 13 +- .../src/settings/DesktopSavedEnvironments.ts | 45 +- apps/mobile/app.config.ts | 5 + apps/mobile/eas.json | 3 +- apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 13 +- apps/mobile/src/app/connections/new.tsx | 11 +- apps/mobile/src/app/index.tsx | 16 +- .../src/app/new/add-project/repository.tsx | 2 +- apps/mobile/src/app/new/index.tsx | 30 +- apps/mobile/src/app/settings/environments.tsx | 395 +- apps/mobile/src/app/settings/index.tsx | 215 +- apps/mobile/src/components/ProjectFavicon.tsx | 63 +- apps/mobile/src/connection/catalog-store.ts | 122 + apps/mobile/src/connection/catalog.ts | 5 + apps/mobile/src/connection/migration.test.ts | 78 + apps/mobile/src/connection/migration.ts | 110 + apps/mobile/src/connection/onboarding.ts | 35 + apps/mobile/src/connection/platform.ts | 200 + apps/mobile/src/connection/runtime.ts | 16 + apps/mobile/src/connection/storage.test.ts | 84 + apps/mobile/src/connection/storage.ts | 432 + .../liveActivityPreferences.test.ts | 114 +- .../liveActivityPreferences.ts | 2 +- .../agent-awareness/registrationPayload.ts | 4 +- .../remoteRegistration.test.ts | 238 +- .../agent-awareness/remoteRegistration.ts | 179 +- .../features/cloud/CloudAuthProvider.test.ts | 60 + .../src/features/cloud/CloudAuthProvider.tsx | 139 +- .../src/features/cloud/cloudDebugLog.ts | 18 + .../cloudEnvironmentPresentation.test.ts | 88 + .../cloud/cloudEnvironmentPresentation.ts | 53 + apps/mobile/src/features/cloud/dpop.test.ts | 12 +- apps/mobile/src/features/cloud/dpop.ts | 2 +- .../features/cloud/linkEnvironment.test.ts | 16 +- .../src/features/cloud/linkEnvironment.ts | 73 +- .../src/features/cloud/managedRelayLayer.ts | 42 +- .../src/features/cloud/managedRelayState.ts | 37 +- .../cloud/managedRelayTokenStore.test.ts | 51 + .../features/cloud/managedRelayTokenStore.ts | 107 + .../src/features/cloud/publicConfig.test.ts | 8 +- .../mobile/src/features/cloud/publicConfig.ts | 6 +- .../connection/ConnectionEnvironmentRow.tsx | 91 +- .../connection/ConnectionStatusDot.tsx | 17 +- .../EnvironmentConnectionNotice.tsx | 108 + .../src/features/connection/connectionTone.ts | 16 +- .../connection/environmentSections.test.ts | 130 + .../connection/environmentSections.ts | 31 + .../connection/useConnectionController.ts | 125 + apps/mobile/src/features/home/HomeScreen.tsx | 289 +- .../observability/mobileTracing.test.ts | 53 - .../features/observability/tracing.test.ts | 97 + .../{mobileTracing.ts => tracing.ts} | 17 +- .../features/projects/AddProjectScreen.tsx | 183 +- .../src/features/review/ReviewSheet.tsx | 89 +- .../review/reviewAvailability.test.ts | 47 + .../src/features/review/reviewAvailability.ts | 19 + .../features/review/reviewDiffPreviewState.ts | 110 - .../src/features/review/useReviewSections.ts | 199 +- .../features/terminal/ThreadTerminalPanel.tsx | 146 +- .../terminal/ThreadTerminalRouteScreen.tsx | 789 +- .../features/terminal/terminalMenu.test.ts | 2 +- .../src/features/terminal/terminalMenu.ts | 2 +- .../terminal/threadTerminalPanelModel.test.ts | 40 + .../terminal/threadTerminalPanelModel.ts | 40 + .../features/threads/NewTaskDraftScreen.tsx | 164 +- .../features/threads/PendingApprovalCard.tsx | 2 +- .../features/threads/PendingUserInputCard.tsx | 2 +- .../src/features/threads/ThreadComposer.tsx | 257 +- .../features/threads/ThreadDetailScreen.tsx | 40 +- .../src/features/threads/ThreadFeed.tsx | 177 +- .../features/threads/ThreadGitControls.tsx | 2 +- .../threads/ThreadNavigationDrawer.tsx | 215 +- .../features/threads/ThreadRouteScreen.tsx | 184 +- .../features/threads/claudeEffortOptions.ts | 10 - .../features/threads/git/GitBranchesSheet.tsx | 15 +- .../features/threads/git/GitCommitSheet.tsx | 15 +- .../features/threads/git/GitConfirmSheet.tsx | 2 +- .../features/threads/git/GitOverviewSheet.tsx | 17 +- .../threads/new-task-flow-provider.tsx | 213 +- .../threads/threadContentPresentation.test.ts | 57 + .../threads/threadContentPresentation.ts | 43 + .../features/threads/threadPresentation.ts | 15 +- .../features/threads/use-project-actions.ts | 272 +- apps/mobile/src/lib/authClientMetadata.ts | 2 +- apps/mobile/src/lib/connection.test.ts | 6 +- apps/mobile/src/lib/connection.ts | 58 +- .../src/lib/{mobileLayout.ts => layout.ts} | 11 +- apps/mobile/src/lib/modelOptions.test.ts | 52 + apps/mobile/src/lib/modelOptions.ts | 53 +- apps/mobile/src/lib/providerOptions.test.ts | 100 + apps/mobile/src/lib/providerOptions.ts | 141 + apps/mobile/src/lib/repositoryGroups.test.ts | 19 +- apps/mobile/src/lib/repositoryGroups.ts | 35 +- apps/mobile/src/lib/routes.ts | 4 +- apps/mobile/src/lib/runtime.ts | 28 +- apps/mobile/src/lib/storage.test.ts | 2 +- apps/mobile/src/lib/storage.ts | 104 +- apps/mobile/src/lib/threadActivity.ts | 15 +- apps/mobile/src/state/assets.ts | 29 + apps/mobile/src/state/auth.ts | 5 + apps/mobile/src/state/entities.ts | 59 + .../src/state/environment-session-registry.ts | 48 - apps/mobile/src/state/environments.ts | 56 + apps/mobile/src/state/filesystem.ts | 5 + apps/mobile/src/state/git.ts | 5 + apps/mobile/src/state/orchestration.ts | 5 + apps/mobile/src/state/presentation.ts | 31 + apps/mobile/src/state/projects.ts | 12 + apps/mobile/src/state/queries.test.ts | 62 + apps/mobile/src/state/queries.ts | 134 + apps/mobile/src/state/query.ts | 36 + apps/mobile/src/state/queryTargets.ts | 51 + apps/mobile/src/state/relay.ts | 6 + apps/mobile/src/state/remote-runtime-types.ts | 23 +- apps/mobile/src/state/review.ts | 5 + apps/mobile/src/state/server.ts | 14 + apps/mobile/src/state/session.ts | 21 + apps/mobile/src/state/shell.ts | 17 + apps/mobile/src/state/sourceControl.ts | 5 + apps/mobile/src/state/terminal.ts | 5 + .../mobile/src/state/thread-outbox-manager.ts | 108 + apps/mobile/src/state/thread-outbox-model.ts | 121 + .../mobile/src/state/thread-outbox-storage.ts | 64 + apps/mobile/src/state/thread-outbox.test.ts | 227 + apps/mobile/src/state/thread-outbox.ts | 29 + apps/mobile/src/state/threads.ts | 45 + apps/mobile/src/state/use-atom-command.ts | 23 + .../mobile/src/state/use-atom-query-runner.ts | 30 + apps/mobile/src/state/use-checkpoint-diff.ts | 16 - .../src/state/use-composer-drafts.test.ts | 28 + apps/mobile/src/state/use-composer-drafts.ts | 85 +- .../src/state/use-composer-path-search.ts | 47 +- .../src/state/use-environment-runtime.ts | 76 - .../mobile/src/state/use-filesystem-browse.ts | 82 - apps/mobile/src/state/use-remote-catalog.ts | 198 - .../use-remote-environment-registry.test.ts | 430 - .../state/use-remote-environment-registry.ts | 873 +- .../src/state/use-selected-thread-commands.ts | 187 - .../state/use-selected-thread-git-actions.ts | 449 +- .../state/use-selected-thread-git-state.ts | 16 +- .../src/state/use-selected-thread-requests.ts | 69 +- apps/mobile/src/state/use-shell-snapshot.ts | 111 - .../src/state/use-source-control-discovery.ts | 78 - apps/mobile/src/state/use-terminal-session.ts | 142 +- .../src/state/use-thread-composer-state.ts | 236 +- apps/mobile/src/state/use-thread-detail.ts | 82 +- .../src/state/use-thread-outbox-drain.ts | 210 + apps/mobile/src/state/use-thread-outbox.ts | 28 + apps/mobile/src/state/use-thread-selection.ts | 76 +- apps/mobile/src/state/use-vcs-action-state.ts | 66 +- apps/mobile/src/state/use-vcs-refs.ts | 51 - apps/mobile/src/state/use-vcs-status.ts | 62 - apps/mobile/src/state/vcs.ts | 9 + apps/mobile/src/state/workspace.ts | 30 + apps/mobile/src/state/workspaceModel.test.ts | 123 + apps/mobile/src/state/workspaceModel.ts | 107 + apps/server/src/assets/AssetAccess.test.ts | 9 +- apps/server/src/auth/http.ts | 4 + .../src/cloud/ManagedEndpointRuntime.test.ts | 23 +- .../src/cloud/ManagedEndpointRuntime.ts | 51 +- apps/server/src/cloud/http.test.ts | 45 +- apps/server/src/cloud/http.ts | 18 +- apps/server/src/cloud/traceRelayRequest.ts | 21 + apps/server/src/git/GitManager.ts | 21 +- apps/server/src/git/GitWorkflowService.ts | 7 +- apps/server/src/http.ts | 3 +- .../Layers/ProviderCommandReactor.test.ts | 2 +- .../src/relay/AgentAwarenessRelay.test.ts | 21 + apps/server/src/relay/AgentAwarenessRelay.ts | 72 +- apps/server/src/server.ts | 4 +- .../src/terminal/Layers/Manager.test.ts | 49 + apps/server/src/terminal/Layers/Manager.ts | 28 +- apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 + apps/server/src/vcs/GitVcsDriverCore.ts | 12 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 147 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 16 +- apps/web/package.json | 9 +- apps/web/src/assets/assetUrls.test.ts | 15 + apps/web/src/assets/assetUrls.ts | 121 +- apps/web/src/browser/ElectronBrowserHost.tsx | 6 +- apps/web/src/browser/HostedBrowserWebview.tsx | 4 +- apps/web/src/browser/browserRecording.ts | 91 +- .../src/browser/browserTargetResolver.test.ts | 22 +- apps/web/src/browser/browserTargetResolver.ts | 6 +- apps/web/src/browser/openFileInPreview.ts | 106 +- apps/web/src/clientPersistenceStorage.test.ts | 58 +- apps/web/src/clientPersistenceStorage.ts | 190 +- apps/web/src/cloud/dpop.test.ts | 43 +- apps/web/src/cloud/linkEnvironment.test.ts | 968 +-- apps/web/src/cloud/linkEnvironment.ts | 370 +- apps/web/src/cloud/linkEnvironmentAtoms.ts | 33 + apps/web/src/cloud/managedAuth.test.ts | 55 + apps/web/src/cloud/managedAuth.tsx | 111 +- apps/web/src/cloud/managedRelayLayer.ts | 34 +- apps/web/src/cloud/managedRelayState.ts | 32 +- apps/web/src/cloud/primaryCloudLinkState.ts | 58 +- apps/web/src/commandPaletteContext.tsx | 29 + apps/web/src/commandPaletteStore.ts | 32 - apps/web/src/components/AppSidebarLayout.tsx | 26 - apps/web/src/components/BranchToolbar.tsx | 20 +- .../BranchToolbarBranchSelector.tsx | 169 +- .../src/components/ChatMarkdown.browser.tsx | 892 -- apps/web/src/components/ChatMarkdown.tsx | 223 +- apps/web/src/components/ChatView.browser.tsx | 7456 ----------------- .../web/src/components/ChatView.logic.test.ts | 692 +- apps/web/src/components/ChatView.logic.ts | 31 +- apps/web/src/components/ChatView.tsx | 2387 +++--- .../components/CommandPalette.logic.test.ts | 5 +- .../src/components/CommandPalette.logic.ts | 8 +- apps/web/src/components/CommandPalette.tsx | 537 +- apps/web/src/components/DiffPanel.tsx | 122 +- .../src/components/DiffPanelShell.browser.tsx | 22 - .../components/GitActionsControl.browser.tsx | 457 - apps/web/src/components/GitActionsControl.tsx | 412 +- .../components/KeybindingsToast.browser.tsx | 636 -- .../KeybindingsUpdateToast.logic.test.ts | 73 + .../KeybindingsUpdateToast.logic.ts | 45 + apps/web/src/components/PlanSidebar.tsx | 48 +- apps/web/src/components/ProjectFavicon.tsx | 42 +- .../src/components/ProjectScriptsControl.tsx | 42 +- ...iderUpdateLaunchNotification.logic.test.ts | 23 +- .../ProviderUpdateLaunchNotification.logic.ts | 19 +- .../ProviderUpdateLaunchNotification.tsx | 48 +- .../components/PullRequestThreadDialog.tsx | 53 +- .../components/Sidebar.dblclick.browser.tsx | 255 - apps/web/src/components/Sidebar.logic.test.ts | 106 +- apps/web/src/components/Sidebar.logic.ts | 43 +- apps/web/src/components/Sidebar.tsx | 607 +- .../SlowRpcRequestToastCoordinator.tsx | 73 + .../src/components/ThreadStatusIndicators.tsx | 50 +- .../ThreadTerminalDrawer.browser.tsx | 425 - .../src/components/ThreadTerminalDrawer.tsx | 458 +- .../WebSocketConnectionSurface.logic.test.ts | 114 - .../components/WebSocketConnectionSurface.tsx | 427 - .../components/auth/PairingRouteSurface.tsx | 37 +- .../components/chat/ChangedFilesTree.test.tsx | 58 +- apps/web/src/components/chat/ChatComposer.tsx | 16 +- apps/web/src/components/chat/ChatHeader.tsx | 25 +- .../CompactComposerControlsMenu.browser.tsx | 326 - .../components/chat/ComposerBannerStack.tsx | 12 +- .../chat/ComposerPendingApprovalActions.tsx | 2 +- .../ComposerPendingReviewComments.browser.tsx | 41 - .../components/chat/ExpandedImageDialog.tsx | 22 +- .../chat/MessagesTimeline.browser.tsx | 477 -- .../chat/MessagesTimeline.logic.test.ts | 137 +- .../components/chat/MessagesTimeline.logic.ts | 13 +- .../components/chat/MessagesTimeline.test.tsx | 6 + .../src/components/chat/MessagesTimeline.tsx | 12 +- .../components/chat/ModelPickerContent.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 42 +- .../src/components/chat/ProposedPlanCard.tsx | 50 +- .../chat/ProviderModelPicker.browser.tsx | 1316 --- .../components/chat/ProviderModelPicker.tsx | 8 - .../clerk/DesktopClerkSignIn.browser.tsx | 71 - .../RelayClientInstallDialog.browser.tsx | 47 - .../desktop/SshPasswordPromptDialog.tsx | 100 +- .../diffs/AnnotatableFileDiff.browser.tsx | 122 - .../files/FilePreviewPanel.browser.tsx | 168 - .../src/components/files/FilePreviewPanel.tsx | 56 +- .../files/fileSaveCoordinator.test.ts | 21 +- .../components/files/fileSaveCoordinator.ts | 19 +- .../files/projectFilesQueryState.test.ts | 93 +- .../files/projectFilesQueryState.ts | 151 +- .../components/preview/AgentBrowserCursor.tsx | 28 +- .../preview/PreviewAutomationOwner.test.ts | 31 + .../preview/PreviewAutomationOwner.tsx | 209 +- .../preview/PreviewChromeRow.browser.tsx | 85 - .../components/preview/PreviewChromeRow.tsx | 9 +- .../src/components/preview/PreviewView.tsx | 38 +- .../components/preview/openDiscoveredPort.ts | 24 +- .../preview/openPreviewSession.test.ts | 36 +- .../components/preview/openPreviewSession.ts | 44 +- .../preview/openTerminalLinkInPreview.ts | 39 +- .../components/preview/previewSessionState.ts | 77 - .../components/preview/usePreviewBridge.ts | 17 +- .../components/preview/usePreviewSession.ts | 189 +- .../settings/AddProviderInstanceDialog.tsx | 32 +- .../settings/ConnectionsSettings.tsx | 1246 ++- .../settings/DiagnosticsSettings.tsx | 143 +- .../settings/KeybindingsSettings.tsx | 129 +- .../settings/ProviderInstanceCard.tsx | 6 +- .../settings/SettingsPanels.browser.tsx | 1541 ---- .../components/settings/SettingsPanels.tsx | 275 +- .../settings/SourceControlSettings.tsx | 21 +- .../sidebar/SidebarProviderUpdatePill.tsx | 5 +- .../components/sidebar/SidebarUpdatePill.tsx | 13 +- apps/web/src/composerDraftStore.test.ts | 49 +- apps/web/src/composerDraftStore.ts | 72 +- apps/web/src/connection/catalog.ts | 5 + apps/web/src/connection/onboarding.ts | 38 + apps/web/src/connection/platform.test.ts | 88 + apps/web/src/connection/platform.ts | 352 + apps/web/src/connection/runtime.ts | 16 + apps/web/src/connection/storage.test.ts | 77 + apps/web/src/connection/storage.ts | 536 ++ apps/web/src/diffFileActions.test.ts | 2 +- apps/web/src/editorPreferences.ts | 71 +- apps/web/src/environmentApi.ts | 121 - apps/web/src/environmentGrouping.test.ts | 656 +- apps/web/src/environments/primary/context.ts | 28 +- .../src/environments/primary/httpClient.ts | 2 +- apps/web/src/environments/primary/index.ts | 1 - apps/web/src/environments/primary/target.ts | 8 +- .../src/environments/runtime/catalog.test.ts | 188 - apps/web/src/environments/runtime/catalog.ts | 410 - .../environments/runtime/connection.test.ts | 295 - .../src/environments/runtime/connection.ts | 7 - apps/web/src/environments/runtime/index.ts | 32 - .../service.addSavedEnvironment.test.ts | 1111 --- .../runtime/service.savedEnvironments.test.ts | 332 - .../service.threadSubscriptions.test.ts | 667 -- apps/web/src/environments/runtime/service.ts | 2103 ----- apps/web/src/historyBootstrap.test.ts | 14 + apps/web/src/hooks/useCommitOnBlur.ts | 22 +- apps/web/src/hooks/useHandleNewThread.ts | 66 +- apps/web/src/hooks/useLocalStorage.ts | 116 +- apps/web/src/hooks/useResizableWidth.ts | 21 +- apps/web/src/hooks/useSettings.ts | 77 +- apps/web/src/hooks/useThreadActions.ts | 310 +- apps/web/src/hooks/useTurnDiffSummaries.ts | 8 +- apps/web/src/lib/archivedThreadsState.ts | 66 +- apps/web/src/lib/chatThreadActions.test.ts | 2 +- apps/web/src/lib/chatThreadActions.ts | 2 +- apps/web/src/lib/checkpointDiffState.ts | 61 +- apps/web/src/lib/composerPathSearchState.ts | 61 +- .../src/lib/desktopUpdateReactQuery.test.ts | 50 - apps/web/src/lib/desktopUpdateReactQuery.ts | 42 - apps/web/src/lib/processDiagnosticsState.ts | 140 - apps/web/src/lib/projectPaths.ts | 2 +- apps/web/src/lib/runtime.ts | 32 +- apps/web/src/lib/sourceControlActions.ts | 487 +- .../src/lib/sourceControlDiscoveryState.ts | 95 - .../src/lib/terminalUiStateCleanup.test.ts | 2 +- apps/web/src/lib/threadSort.test.ts | 22 +- apps/web/src/lib/threadSort.ts | 2 +- apps/web/src/lib/traceDiagnosticsState.ts | 65 - apps/web/src/lib/turnDiffTree.test.ts | 30 +- apps/web/src/lib/vcsRefState.ts | 48 - apps/web/src/lib/vcsStatusState.ts | 65 - apps/web/src/localApi.test.ts | 772 +- apps/web/src/localApi.ts | 133 +- apps/web/src/logicalProject.ts | 48 +- apps/web/src/modelPickerOpenState.ts | 17 - apps/web/src/modelPickerVisibility.ts | 13 + apps/web/src/observability/clientTracing.ts | 39 +- apps/web/src/portDiscoveryState.ts | 53 +- apps/web/src/previewStateStore.test.ts | 138 +- apps/web/src/previewStateStore.ts | 414 +- apps/web/src/projectScripts.ts | 2 +- apps/web/src/rightPanelStore.test.ts | 2 +- apps/web/src/rightPanelStore.ts | 2 +- apps/web/src/router.ts | 14 +- apps/web/src/routes/__root.tsx | 278 +- .../routes/_chat.$environmentId.$threadId.tsx | 43 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 49 +- apps/web/src/routes/_chat.index.tsx | 8 +- apps/web/src/routes/_chat.tsx | 13 +- apps/web/src/rpc/serverState.test.ts | 369 - apps/web/src/rpc/serverState.ts | 305 - apps/web/src/rpc/transportError.ts | 2 +- apps/web/src/rpc/wsConnectionState.test.ts | 107 - apps/web/src/rpc/wsConnectionState.ts | 238 - apps/web/src/rpc/wsTransport.test.ts | 411 - apps/web/src/rpc/wsTransport.ts | 63 - apps/web/src/session-logic.test.ts | 18 +- apps/web/src/session-logic.ts | 26 +- apps/web/src/shortcutModifierState.test.ts | 91 +- apps/web/src/shortcutModifierState.ts | 82 +- apps/web/src/sidebarProjectGrouping.ts | 4 +- apps/web/src/state/assets.ts | 5 + apps/web/src/state/auth.ts | 5 + .../src/state/desktopNetworkAccess.test.ts | 50 + apps/web/src/state/desktopNetworkAccess.ts | 79 + apps/web/src/state/desktopSshHosts.test.ts | 41 + apps/web/src/state/desktopSshHosts.ts | 49 + apps/web/src/state/desktopUpdate.test.ts | 111 + apps/web/src/state/desktopUpdate.ts | 57 + apps/web/src/state/entities.ts | 197 + apps/web/src/state/environments.ts | 99 + apps/web/src/state/filesystem.ts | 5 + apps/web/src/state/git.ts | 5 + apps/web/src/state/orchestration.ts | 5 + apps/web/src/state/presentation.ts | 31 + apps/web/src/state/preview.ts | 5 + apps/web/src/state/projects.ts | 12 + apps/web/src/state/queries.ts | 257 + apps/web/src/state/query.ts | 36 + apps/web/src/state/relay.ts | 6 + apps/web/src/state/review.ts | 5 + apps/web/src/state/server.ts | 100 + apps/web/src/state/session.ts | 28 + apps/web/src/state/shell.ts | 17 + apps/web/src/state/sourceControl.ts | 5 + apps/web/src/state/sourceControlActions.ts | 356 + apps/web/src/state/terminal.ts | 5 + apps/web/src/state/terminalSessions.ts | 90 + apps/web/src/state/threads.ts | 45 + apps/web/src/state/use-atom-command.ts | 23 + apps/web/src/state/use-atom-query-runner.ts | 30 + apps/web/src/state/vcs.ts | 9 + apps/web/src/store.test.ts | 1083 --- apps/web/src/store.ts | 2050 ----- apps/web/src/storeSelectors.ts | 68 - apps/web/src/terminalSessionState.ts | 77 - apps/web/src/terminalUiStateStore.test.ts | 26 +- apps/web/src/terminalUiStateStore.ts | 209 +- apps/web/src/threadDerivation.ts | 152 - apps/web/src/threadRoutes.test.ts | 2 +- apps/web/src/threadRoutes.ts | 2 +- apps/web/src/types.ts | 160 +- apps/web/src/uiStateStore.test.ts | 609 +- apps/web/src/uiStateStore.ts | 499 +- apps/web/src/worktreeCleanup.test.ts | 6 +- apps/web/src/worktreeCleanup.ts | 6 +- apps/web/test/authHttpHandlers.ts | 103 - apps/web/test/wsRpcHarness.ts | 185 - apps/web/vite.config.ts | 29 +- docs/architecture/connection-runtime.md | 137 + infra/relay/README.md | 2 +- infra/relay/src/auth/RelayTokens.test.ts | 8 +- infra/relay/src/auth/RelayTokens.ts | 19 + .../environments/EnvironmentConnector.test.ts | 1 + .../src/environments/EnvironmentConnector.ts | 46 +- infra/relay/src/http/Api.ts | 5 +- .../rules/no-inline-schema-compile.ts | 4 + .../no-manual-effect-runtime-in-tests.ts | 2 +- packages/client-runtime/README.md | 31 + packages/client-runtime/package.json | 127 +- .../client-runtime/src/advertisedEndpoint.ts | 1 - .../src/archivedThreadsState.test.ts | 96 - .../src/archivedThreadsState.ts | 138 - .../client-runtime/src/authorization/index.ts | 4 + .../src/authorization/layer.test.ts | 344 + .../client-runtime/src/authorization/layer.ts | 268 + .../src/{ => authorization}/remote.test.ts | 10 +- .../src/authorization/remote.ts | 214 + .../src/authorization/service.ts | 39 + .../src/authorization/tokenStore.ts | 30 + .../src/checkpointDiffState.test.ts | 135 - .../client-runtime/src/checkpointDiffState.ts | 313 - .../src/composerPathSearchState.test.ts | 185 - .../src/composerPathSearchState.ts | 341 - .../client-runtime/src/connection/catalog.ts | 143 + .../src/connection/connectivity.ts | 13 + .../client-runtime/src/connection/driver.ts | 66 + .../client-runtime/src/connection/errors.ts | 140 + .../client-runtime/src/connection/index.ts | 12 + .../client-runtime/src/connection/layer.ts | 46 + .../client-runtime/src/connection/model.ts | 168 + .../src/connection/onboarding.test.ts | 257 + .../src/connection/onboarding.ts | 267 + .../src/connection/presentation.test.ts | 184 + .../src/connection/presentation.ts | 122 + .../src/connection/registry.test.ts | 944 +++ .../client-runtime/src/connection/registry.ts | 576 ++ .../src/connection/resolver.test.ts | 423 + .../client-runtime/src/connection/resolver.ts | 257 + .../src/connection/supervisor.test.ts | 847 ++ .../src/connection/supervisor.ts | 724 ++ .../client-runtime/src/connection/wakeups.ts | 11 + .../src/environment/descriptor.ts | 17 + .../endpoint.test.ts} | 2 +- .../src/environment/endpoint.ts | 9 + .../client-runtime/src/environment/index.ts | 4 + .../knownEnvironment.test.ts | 28 +- .../src/{ => environment}/knownEnvironment.ts | 12 - .../src/{ => environment}/scoped.ts | 0 .../src/environmentConnection.ts | 244 - .../src/environmentRuntimeState.test.ts | 75 - .../src/environmentRuntimeState.ts | 104 - .../src/errors/errorTrace.test.ts | 25 + .../client-runtime/src/errors/errorTrace.ts | 18 + packages/client-runtime/src/errors/index.ts | 2 + .../transport.test.ts} | 13 +- .../transport.ts} | 7 +- .../src/filesystemBrowseState.test.ts | 119 - .../src/filesystemBrowseState.ts | 339 - .../client-runtime/src/gitActions.test.ts | 43 - packages/client-runtime/src/index.ts | 30 - .../client-runtime/src/managedRelay.test.ts | 185 - packages/client-runtime/src/managedRelay.ts | 516 -- .../src/managedRelayState.test.ts | 155 - .../src/operations/commands.test.ts | 140 + .../client-runtime/src/operations/commands.ts | 256 + .../client-runtime/src/operations/index.ts | 2 + .../projects.test.ts} | 6 +- .../{addProject.ts => operations/projects.ts} | 12 +- .../src/platform/capabilities.ts | 61 + packages/client-runtime/src/platform/index.ts | 4 + .../src/platform/persistence.ts | 84 + .../client-runtime/src/platform/source.ts | 11 + .../src/platform/storageDocument.test.ts | 146 + .../src/platform/storageDocument.ts | 141 + .../src/reconnectBackoff.test.ts | 65 - .../client-runtime/src/reconnectBackoff.ts | 47 - .../src/relay/discovery.test.ts | 371 + .../client-runtime/src/relay/discovery.ts | 333 + packages/client-runtime/src/relay/index.ts | 3 + .../src/relay/managedRelay.test.ts | 515 ++ .../client-runtime/src/relay/managedRelay.ts | 764 ++ .../src/relay/managedRelayState.test.ts | 383 + .../src/{ => relay}/managedRelayState.ts | 225 +- packages/client-runtime/src/remote.ts | 371 - .../client-runtime/src/rpc/client.test.ts | 391 + packages/client-runtime/src/rpc/client.ts | 247 + packages/client-runtime/src/rpc/http.ts | 154 + packages/client-runtime/src/rpc/index.ts | 4 + packages/client-runtime/src/rpc/protocol.ts | 8 + .../client-runtime/src/rpc/session.test.ts | 276 + packages/client-runtime/src/rpc/session.ts | 144 + .../src/shellSnapshotState.test.ts | 128 - .../client-runtime/src/shellSnapshotState.ts | 140 - .../src/sourceControlDiscoveryState.test.ts | 309 - .../src/sourceControlDiscoveryState.ts | 401 - .../src/state/archivedThreads.test.ts | 15 + .../src/state/archivedThreads.ts | 30 + .../client-runtime/src/state/assets.test.ts | 82 + packages/client-runtime/src/state/assets.ts | 54 + .../client-runtime/src/state/auth.test.ts | 79 + packages/client-runtime/src/state/auth.ts | 90 + .../src/state/checkpointDiff.ts | 25 + .../src/state/composerPathSearch.ts | 19 + .../client-runtime/src/state/connections.ts | 120 + .../client-runtime/src/state/entities.test.ts | 310 + packages/client-runtime/src/state/entities.ts | 81 + .../client-runtime/src/state/filesystem.ts | 16 + packages/client-runtime/src/state/git.ts | 23 + .../src/{ => state}/gitActions.ts | 0 .../src/{shellTypes.ts => state/models.ts} | 29 +- .../client-runtime/src/state/orchestration.ts | 24 + .../client-runtime/src/state/presentation.ts | 69 + packages/client-runtime/src/state/preview.ts | 103 + .../src/state/projectCommands.ts | 106 + .../src/state/projectEntities.ts | 105 + .../{projectPaths.ts => state/projects.ts} | 7 +- .../src/state/relayDiscovery.ts | 42 + packages/client-runtime/src/state/review.ts | 17 + .../client-runtime/src/state/runtime.test.ts | 451 + packages/client-runtime/src/state/runtime.ts | 651 ++ .../client-runtime/src/state/server.test.ts | 54 + packages/client-runtime/src/state/server.ts | 182 + .../client-runtime/src/state/session.test.ts | 21 + packages/client-runtime/src/state/session.ts | 88 + .../src/state/shell-sync.test.ts | 123 + .../client-runtime/src/state/shell.test.ts | 130 + packages/client-runtime/src/state/shell.ts | 314 + .../client-runtime/src/state/shellCommands.ts | 16 + .../shellReducer.test.ts} | 2 +- .../shellReducer.ts} | 2 +- .../client-runtime/src/state/snapshots.ts | 20 + .../client-runtime/src/state/sourceControl.ts | 41 + packages/client-runtime/src/state/terminal.ts | 95 + .../src/state/terminalSession.test.ts | 187 + .../src/state/terminalSession.ts | 194 + .../src/state/threadCommands.ts | 140 + .../client-runtime/src/state/threadDetail.ts | 185 + .../threadReducer.test.ts} | 45 +- .../threadReducer.ts} | 33 +- .../client-runtime/src/state/threadShell.ts | 186 + .../src/state/threads-sync.test.ts | 406 + packages/client-runtime/src/state/threads.ts | 269 + packages/client-runtime/src/state/vcs.ts | 85 + .../src/state/vcsAction.test.ts | 342 + .../client-runtime/src/state/vcsAction.ts | 499 ++ .../src/state/vcsCommandScheduler.ts | 13 + packages/client-runtime/src/state/vcsRef.ts | 9 + .../client-runtime/src/state/vcsStatus.ts | 6 + .../src/terminalSessionState.test.ts | 558 -- .../src/terminalSessionState.ts | 605 -- .../src/threadDetailState.test.ts | 322 - .../client-runtime/src/threadDetailState.ts | 444 - .../client-runtime/src/vcsActionState.test.ts | 292 - packages/client-runtime/src/vcsActionState.ts | 458 - .../client-runtime/src/vcsRefState.test.ts | 399 - packages/client-runtime/src/vcsRefState.ts | 451 - .../client-runtime/src/vcsStatusState.test.ts | 363 - packages/client-runtime/src/vcsStatusState.ts | 306 - .../client-runtime/src/wsRpcClient.test.ts | 186 - packages/client-runtime/src/wsRpcClient.ts | 440 - packages/client-runtime/src/wsRpcProtocol.ts | 319 - .../client-runtime/src/wsTransport.test.ts | 959 --- packages/client-runtime/src/wsTransport.ts | 377 - packages/client-runtime/tsconfig.json | 1 - packages/contracts/src/ipc.ts | 17 +- packages/contracts/src/relay.ts | 1 + packages/shared/package.json | 8 +- packages/shared/src/Net.test.ts | 4 +- packages/shared/src/Net.ts | 95 +- packages/shared/src/relayJwt.ts | 3 +- packages/shared/src/relayTracing.ts | 73 +- pnpm-lock.yaml | 265 +- vite.config.ts | 17 +- 606 files changed, 37206 insertions(+), 53661 deletions(-) create mode 100644 apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts create mode 100644 apps/desktop/src/app/DesktopConnectionCatalogStore.ts create mode 100644 apps/desktop/src/ipc/methods/connectionCatalog.ts delete mode 100644 apps/desktop/src/ipc/methods/savedEnvironments.ts create mode 100644 apps/mobile/src/connection/catalog-store.ts create mode 100644 apps/mobile/src/connection/catalog.ts create mode 100644 apps/mobile/src/connection/migration.test.ts create mode 100644 apps/mobile/src/connection/migration.ts create mode 100644 apps/mobile/src/connection/onboarding.ts create mode 100644 apps/mobile/src/connection/platform.ts create mode 100644 apps/mobile/src/connection/runtime.ts create mode 100644 apps/mobile/src/connection/storage.test.ts create mode 100644 apps/mobile/src/connection/storage.ts create mode 100644 apps/mobile/src/features/cloud/CloudAuthProvider.test.ts create mode 100644 apps/mobile/src/features/cloud/cloudDebugLog.ts create mode 100644 apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts create mode 100644 apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts create mode 100644 apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts create mode 100644 apps/mobile/src/features/cloud/managedRelayTokenStore.ts create mode 100644 apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx create mode 100644 apps/mobile/src/features/connection/environmentSections.test.ts create mode 100644 apps/mobile/src/features/connection/environmentSections.ts create mode 100644 apps/mobile/src/features/connection/useConnectionController.ts delete mode 100644 apps/mobile/src/features/observability/mobileTracing.test.ts create mode 100644 apps/mobile/src/features/observability/tracing.test.ts rename apps/mobile/src/features/observability/{mobileTracing.ts => tracing.ts} (64%) create mode 100644 apps/mobile/src/features/review/reviewAvailability.test.ts create mode 100644 apps/mobile/src/features/review/reviewAvailability.ts delete mode 100644 apps/mobile/src/features/review/reviewDiffPreviewState.ts create mode 100644 apps/mobile/src/features/terminal/threadTerminalPanelModel.test.ts create mode 100644 apps/mobile/src/features/terminal/threadTerminalPanelModel.ts delete mode 100644 apps/mobile/src/features/threads/claudeEffortOptions.ts create mode 100644 apps/mobile/src/features/threads/threadContentPresentation.test.ts create mode 100644 apps/mobile/src/features/threads/threadContentPresentation.ts rename apps/mobile/src/lib/{mobileLayout.ts => layout.ts} (74%) create mode 100644 apps/mobile/src/lib/modelOptions.test.ts create mode 100644 apps/mobile/src/lib/providerOptions.test.ts create mode 100644 apps/mobile/src/lib/providerOptions.ts create mode 100644 apps/mobile/src/state/assets.ts create mode 100644 apps/mobile/src/state/auth.ts create mode 100644 apps/mobile/src/state/entities.ts delete mode 100644 apps/mobile/src/state/environment-session-registry.ts create mode 100644 apps/mobile/src/state/environments.ts create mode 100644 apps/mobile/src/state/filesystem.ts create mode 100644 apps/mobile/src/state/git.ts create mode 100644 apps/mobile/src/state/orchestration.ts create mode 100644 apps/mobile/src/state/presentation.ts create mode 100644 apps/mobile/src/state/projects.ts create mode 100644 apps/mobile/src/state/queries.test.ts create mode 100644 apps/mobile/src/state/queries.ts create mode 100644 apps/mobile/src/state/query.ts create mode 100644 apps/mobile/src/state/queryTargets.ts create mode 100644 apps/mobile/src/state/relay.ts create mode 100644 apps/mobile/src/state/review.ts create mode 100644 apps/mobile/src/state/server.ts create mode 100644 apps/mobile/src/state/session.ts create mode 100644 apps/mobile/src/state/shell.ts create mode 100644 apps/mobile/src/state/sourceControl.ts create mode 100644 apps/mobile/src/state/terminal.ts create mode 100644 apps/mobile/src/state/thread-outbox-manager.ts create mode 100644 apps/mobile/src/state/thread-outbox-model.ts create mode 100644 apps/mobile/src/state/thread-outbox-storage.ts create mode 100644 apps/mobile/src/state/thread-outbox.test.ts create mode 100644 apps/mobile/src/state/thread-outbox.ts create mode 100644 apps/mobile/src/state/threads.ts create mode 100644 apps/mobile/src/state/use-atom-command.ts create mode 100644 apps/mobile/src/state/use-atom-query-runner.ts delete mode 100644 apps/mobile/src/state/use-checkpoint-diff.ts create mode 100644 apps/mobile/src/state/use-composer-drafts.test.ts delete mode 100644 apps/mobile/src/state/use-environment-runtime.ts delete mode 100644 apps/mobile/src/state/use-filesystem-browse.ts delete mode 100644 apps/mobile/src/state/use-remote-catalog.ts delete mode 100644 apps/mobile/src/state/use-remote-environment-registry.test.ts delete mode 100644 apps/mobile/src/state/use-selected-thread-commands.ts delete mode 100644 apps/mobile/src/state/use-shell-snapshot.ts delete mode 100644 apps/mobile/src/state/use-source-control-discovery.ts create mode 100644 apps/mobile/src/state/use-thread-outbox-drain.ts create mode 100644 apps/mobile/src/state/use-thread-outbox.ts delete mode 100644 apps/mobile/src/state/use-vcs-refs.ts delete mode 100644 apps/mobile/src/state/use-vcs-status.ts create mode 100644 apps/mobile/src/state/vcs.ts create mode 100644 apps/mobile/src/state/workspace.ts create mode 100644 apps/mobile/src/state/workspaceModel.test.ts create mode 100644 apps/mobile/src/state/workspaceModel.ts create mode 100644 apps/server/src/cloud/traceRelayRequest.ts create mode 100644 apps/web/src/assets/assetUrls.test.ts create mode 100644 apps/web/src/cloud/linkEnvironmentAtoms.ts create mode 100644 apps/web/src/cloud/managedAuth.test.ts create mode 100644 apps/web/src/commandPaletteContext.tsx delete mode 100644 apps/web/src/commandPaletteStore.ts delete mode 100644 apps/web/src/components/ChatMarkdown.browser.tsx delete mode 100644 apps/web/src/components/ChatView.browser.tsx delete mode 100644 apps/web/src/components/DiffPanelShell.browser.tsx delete mode 100644 apps/web/src/components/GitActionsControl.browser.tsx delete mode 100644 apps/web/src/components/KeybindingsToast.browser.tsx create mode 100644 apps/web/src/components/KeybindingsUpdateToast.logic.test.ts create mode 100644 apps/web/src/components/KeybindingsUpdateToast.logic.ts delete mode 100644 apps/web/src/components/Sidebar.dblclick.browser.tsx create mode 100644 apps/web/src/components/SlowRpcRequestToastCoordinator.tsx delete mode 100644 apps/web/src/components/ThreadTerminalDrawer.browser.tsx delete mode 100644 apps/web/src/components/WebSocketConnectionSurface.logic.test.ts delete mode 100644 apps/web/src/components/WebSocketConnectionSurface.tsx delete mode 100644 apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx delete mode 100644 apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx delete mode 100644 apps/web/src/components/chat/MessagesTimeline.browser.tsx delete mode 100644 apps/web/src/components/chat/ProviderModelPicker.browser.tsx delete mode 100644 apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx delete mode 100644 apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx delete mode 100644 apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx delete mode 100644 apps/web/src/components/files/FilePreviewPanel.browser.tsx create mode 100644 apps/web/src/components/preview/PreviewAutomationOwner.test.ts delete mode 100644 apps/web/src/components/preview/PreviewChromeRow.browser.tsx delete mode 100644 apps/web/src/components/preview/previewSessionState.ts delete mode 100644 apps/web/src/components/settings/SettingsPanels.browser.tsx create mode 100644 apps/web/src/connection/catalog.ts create mode 100644 apps/web/src/connection/onboarding.ts create mode 100644 apps/web/src/connection/platform.test.ts create mode 100644 apps/web/src/connection/platform.ts create mode 100644 apps/web/src/connection/runtime.ts create mode 100644 apps/web/src/connection/storage.test.ts create mode 100644 apps/web/src/connection/storage.ts delete mode 100644 apps/web/src/environmentApi.ts delete mode 100644 apps/web/src/environments/runtime/catalog.test.ts delete mode 100644 apps/web/src/environments/runtime/catalog.ts delete mode 100644 apps/web/src/environments/runtime/connection.test.ts delete mode 100644 apps/web/src/environments/runtime/connection.ts delete mode 100644 apps/web/src/environments/runtime/index.ts delete mode 100644 apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts delete mode 100644 apps/web/src/environments/runtime/service.savedEnvironments.test.ts delete mode 100644 apps/web/src/environments/runtime/service.threadSubscriptions.test.ts delete mode 100644 apps/web/src/environments/runtime/service.ts delete mode 100644 apps/web/src/lib/desktopUpdateReactQuery.test.ts delete mode 100644 apps/web/src/lib/desktopUpdateReactQuery.ts delete mode 100644 apps/web/src/lib/processDiagnosticsState.ts delete mode 100644 apps/web/src/lib/sourceControlDiscoveryState.ts delete mode 100644 apps/web/src/lib/traceDiagnosticsState.ts delete mode 100644 apps/web/src/lib/vcsRefState.ts delete mode 100644 apps/web/src/lib/vcsStatusState.ts delete mode 100644 apps/web/src/modelPickerOpenState.ts create mode 100644 apps/web/src/modelPickerVisibility.ts delete mode 100644 apps/web/src/rpc/serverState.test.ts delete mode 100644 apps/web/src/rpc/serverState.ts delete mode 100644 apps/web/src/rpc/wsConnectionState.test.ts delete mode 100644 apps/web/src/rpc/wsConnectionState.ts delete mode 100644 apps/web/src/rpc/wsTransport.test.ts delete mode 100644 apps/web/src/rpc/wsTransport.ts create mode 100644 apps/web/src/state/assets.ts create mode 100644 apps/web/src/state/auth.ts create mode 100644 apps/web/src/state/desktopNetworkAccess.test.ts create mode 100644 apps/web/src/state/desktopNetworkAccess.ts create mode 100644 apps/web/src/state/desktopSshHosts.test.ts create mode 100644 apps/web/src/state/desktopSshHosts.ts create mode 100644 apps/web/src/state/desktopUpdate.test.ts create mode 100644 apps/web/src/state/desktopUpdate.ts create mode 100644 apps/web/src/state/entities.ts create mode 100644 apps/web/src/state/environments.ts create mode 100644 apps/web/src/state/filesystem.ts create mode 100644 apps/web/src/state/git.ts create mode 100644 apps/web/src/state/orchestration.ts create mode 100644 apps/web/src/state/presentation.ts create mode 100644 apps/web/src/state/preview.ts create mode 100644 apps/web/src/state/projects.ts create mode 100644 apps/web/src/state/queries.ts create mode 100644 apps/web/src/state/query.ts create mode 100644 apps/web/src/state/relay.ts create mode 100644 apps/web/src/state/review.ts create mode 100644 apps/web/src/state/server.ts create mode 100644 apps/web/src/state/session.ts create mode 100644 apps/web/src/state/shell.ts create mode 100644 apps/web/src/state/sourceControl.ts create mode 100644 apps/web/src/state/sourceControlActions.ts create mode 100644 apps/web/src/state/terminal.ts create mode 100644 apps/web/src/state/terminalSessions.ts create mode 100644 apps/web/src/state/threads.ts create mode 100644 apps/web/src/state/use-atom-command.ts create mode 100644 apps/web/src/state/use-atom-query-runner.ts create mode 100644 apps/web/src/state/vcs.ts delete mode 100644 apps/web/src/store.test.ts delete mode 100644 apps/web/src/store.ts delete mode 100644 apps/web/src/storeSelectors.ts delete mode 100644 apps/web/src/terminalSessionState.ts delete mode 100644 apps/web/src/threadDerivation.ts delete mode 100644 apps/web/test/authHttpHandlers.ts delete mode 100644 apps/web/test/wsRpcHarness.ts create mode 100644 docs/architecture/connection-runtime.md create mode 100644 packages/client-runtime/README.md delete mode 100644 packages/client-runtime/src/advertisedEndpoint.ts delete mode 100644 packages/client-runtime/src/archivedThreadsState.test.ts delete mode 100644 packages/client-runtime/src/archivedThreadsState.ts create mode 100644 packages/client-runtime/src/authorization/index.ts create mode 100644 packages/client-runtime/src/authorization/layer.test.ts create mode 100644 packages/client-runtime/src/authorization/layer.ts rename packages/client-runtime/src/{ => authorization}/remote.test.ts (97%) create mode 100644 packages/client-runtime/src/authorization/remote.ts create mode 100644 packages/client-runtime/src/authorization/service.ts create mode 100644 packages/client-runtime/src/authorization/tokenStore.ts delete mode 100644 packages/client-runtime/src/checkpointDiffState.test.ts delete mode 100644 packages/client-runtime/src/checkpointDiffState.ts delete mode 100644 packages/client-runtime/src/composerPathSearchState.test.ts delete mode 100644 packages/client-runtime/src/composerPathSearchState.ts create mode 100644 packages/client-runtime/src/connection/catalog.ts create mode 100644 packages/client-runtime/src/connection/connectivity.ts create mode 100644 packages/client-runtime/src/connection/driver.ts create mode 100644 packages/client-runtime/src/connection/errors.ts create mode 100644 packages/client-runtime/src/connection/index.ts create mode 100644 packages/client-runtime/src/connection/layer.ts create mode 100644 packages/client-runtime/src/connection/model.ts create mode 100644 packages/client-runtime/src/connection/onboarding.test.ts create mode 100644 packages/client-runtime/src/connection/onboarding.ts create mode 100644 packages/client-runtime/src/connection/presentation.test.ts create mode 100644 packages/client-runtime/src/connection/presentation.ts create mode 100644 packages/client-runtime/src/connection/registry.test.ts create mode 100644 packages/client-runtime/src/connection/registry.ts create mode 100644 packages/client-runtime/src/connection/resolver.test.ts create mode 100644 packages/client-runtime/src/connection/resolver.ts create mode 100644 packages/client-runtime/src/connection/supervisor.test.ts create mode 100644 packages/client-runtime/src/connection/supervisor.ts create mode 100644 packages/client-runtime/src/connection/wakeups.ts create mode 100644 packages/client-runtime/src/environment/descriptor.ts rename packages/client-runtime/src/{advertisedEndpoint.test.ts => environment/endpoint.test.ts} (98%) create mode 100644 packages/client-runtime/src/environment/endpoint.ts create mode 100644 packages/client-runtime/src/environment/index.ts rename packages/client-runtime/src/{ => environment}/knownEnvironment.test.ts (72%) rename packages/client-runtime/src/{ => environment}/knownEnvironment.ts (77%) rename packages/client-runtime/src/{ => environment}/scoped.ts (100%) delete mode 100644 packages/client-runtime/src/environmentConnection.ts delete mode 100644 packages/client-runtime/src/environmentRuntimeState.test.ts delete mode 100644 packages/client-runtime/src/environmentRuntimeState.ts create mode 100644 packages/client-runtime/src/errors/errorTrace.test.ts create mode 100644 packages/client-runtime/src/errors/errorTrace.ts create mode 100644 packages/client-runtime/src/errors/index.ts rename packages/client-runtime/src/{transportError.test.ts => errors/transport.test.ts} (80%) rename packages/client-runtime/src/{transportError.ts => errors/transport.ts} (81%) delete mode 100644 packages/client-runtime/src/filesystemBrowseState.test.ts delete mode 100644 packages/client-runtime/src/filesystemBrowseState.ts delete mode 100644 packages/client-runtime/src/gitActions.test.ts delete mode 100644 packages/client-runtime/src/index.ts delete mode 100644 packages/client-runtime/src/managedRelay.test.ts delete mode 100644 packages/client-runtime/src/managedRelay.ts delete mode 100644 packages/client-runtime/src/managedRelayState.test.ts create mode 100644 packages/client-runtime/src/operations/commands.test.ts create mode 100644 packages/client-runtime/src/operations/commands.ts create mode 100644 packages/client-runtime/src/operations/index.ts rename packages/client-runtime/src/{addProject.test.ts => operations/projects.test.ts} (96%) rename packages/client-runtime/src/{addProject.ts => operations/projects.ts} (94%) create mode 100644 packages/client-runtime/src/platform/capabilities.ts create mode 100644 packages/client-runtime/src/platform/index.ts create mode 100644 packages/client-runtime/src/platform/persistence.ts create mode 100644 packages/client-runtime/src/platform/source.ts create mode 100644 packages/client-runtime/src/platform/storageDocument.test.ts create mode 100644 packages/client-runtime/src/platform/storageDocument.ts delete mode 100644 packages/client-runtime/src/reconnectBackoff.test.ts delete mode 100644 packages/client-runtime/src/reconnectBackoff.ts create mode 100644 packages/client-runtime/src/relay/discovery.test.ts create mode 100644 packages/client-runtime/src/relay/discovery.ts create mode 100644 packages/client-runtime/src/relay/index.ts create mode 100644 packages/client-runtime/src/relay/managedRelay.test.ts create mode 100644 packages/client-runtime/src/relay/managedRelay.ts create mode 100644 packages/client-runtime/src/relay/managedRelayState.test.ts rename packages/client-runtime/src/{ => relay}/managedRelayState.ts (51%) delete mode 100644 packages/client-runtime/src/remote.ts create mode 100644 packages/client-runtime/src/rpc/client.test.ts create mode 100644 packages/client-runtime/src/rpc/client.ts create mode 100644 packages/client-runtime/src/rpc/http.ts create mode 100644 packages/client-runtime/src/rpc/index.ts create mode 100644 packages/client-runtime/src/rpc/protocol.ts create mode 100644 packages/client-runtime/src/rpc/session.test.ts create mode 100644 packages/client-runtime/src/rpc/session.ts delete mode 100644 packages/client-runtime/src/shellSnapshotState.test.ts delete mode 100644 packages/client-runtime/src/shellSnapshotState.ts delete mode 100644 packages/client-runtime/src/sourceControlDiscoveryState.test.ts delete mode 100644 packages/client-runtime/src/sourceControlDiscoveryState.ts create mode 100644 packages/client-runtime/src/state/archivedThreads.test.ts create mode 100644 packages/client-runtime/src/state/archivedThreads.ts create mode 100644 packages/client-runtime/src/state/assets.test.ts create mode 100644 packages/client-runtime/src/state/assets.ts create mode 100644 packages/client-runtime/src/state/auth.test.ts create mode 100644 packages/client-runtime/src/state/auth.ts create mode 100644 packages/client-runtime/src/state/checkpointDiff.ts create mode 100644 packages/client-runtime/src/state/composerPathSearch.ts create mode 100644 packages/client-runtime/src/state/connections.ts create mode 100644 packages/client-runtime/src/state/entities.test.ts create mode 100644 packages/client-runtime/src/state/entities.ts create mode 100644 packages/client-runtime/src/state/filesystem.ts create mode 100644 packages/client-runtime/src/state/git.ts rename packages/client-runtime/src/{ => state}/gitActions.ts (100%) rename packages/client-runtime/src/{shellTypes.ts => state/models.ts} (52%) create mode 100644 packages/client-runtime/src/state/orchestration.ts create mode 100644 packages/client-runtime/src/state/presentation.ts create mode 100644 packages/client-runtime/src/state/preview.ts create mode 100644 packages/client-runtime/src/state/projectCommands.ts create mode 100644 packages/client-runtime/src/state/projectEntities.ts rename packages/client-runtime/src/{projectPaths.ts => state/projects.ts} (98%) create mode 100644 packages/client-runtime/src/state/relayDiscovery.ts create mode 100644 packages/client-runtime/src/state/review.ts create mode 100644 packages/client-runtime/src/state/runtime.test.ts create mode 100644 packages/client-runtime/src/state/runtime.ts create mode 100644 packages/client-runtime/src/state/server.test.ts create mode 100644 packages/client-runtime/src/state/server.ts create mode 100644 packages/client-runtime/src/state/session.test.ts create mode 100644 packages/client-runtime/src/state/session.ts create mode 100644 packages/client-runtime/src/state/shell-sync.test.ts create mode 100644 packages/client-runtime/src/state/shell.test.ts create mode 100644 packages/client-runtime/src/state/shell.ts create mode 100644 packages/client-runtime/src/state/shellCommands.ts rename packages/client-runtime/src/{shellSnapshotReducer.test.ts => state/shellReducer.test.ts} (98%) rename packages/client-runtime/src/{shellSnapshotReducer.ts => state/shellReducer.ts} (95%) create mode 100644 packages/client-runtime/src/state/snapshots.ts create mode 100644 packages/client-runtime/src/state/sourceControl.ts create mode 100644 packages/client-runtime/src/state/terminal.ts create mode 100644 packages/client-runtime/src/state/terminalSession.test.ts create mode 100644 packages/client-runtime/src/state/terminalSession.ts create mode 100644 packages/client-runtime/src/state/threadCommands.ts create mode 100644 packages/client-runtime/src/state/threadDetail.ts rename packages/client-runtime/src/{threadDetailReducer.test.ts => state/threadReducer.test.ts} (93%) rename packages/client-runtime/src/{threadDetailReducer.ts => state/threadReducer.ts} (95%) create mode 100644 packages/client-runtime/src/state/threadShell.ts create mode 100644 packages/client-runtime/src/state/threads-sync.test.ts create mode 100644 packages/client-runtime/src/state/threads.ts create mode 100644 packages/client-runtime/src/state/vcs.ts create mode 100644 packages/client-runtime/src/state/vcsAction.test.ts create mode 100644 packages/client-runtime/src/state/vcsAction.ts create mode 100644 packages/client-runtime/src/state/vcsCommandScheduler.ts create mode 100644 packages/client-runtime/src/state/vcsRef.ts create mode 100644 packages/client-runtime/src/state/vcsStatus.ts delete mode 100644 packages/client-runtime/src/terminalSessionState.test.ts delete mode 100644 packages/client-runtime/src/terminalSessionState.ts delete mode 100644 packages/client-runtime/src/threadDetailState.test.ts delete mode 100644 packages/client-runtime/src/threadDetailState.ts delete mode 100644 packages/client-runtime/src/vcsActionState.test.ts delete mode 100644 packages/client-runtime/src/vcsActionState.ts delete mode 100644 packages/client-runtime/src/vcsRefState.test.ts delete mode 100644 packages/client-runtime/src/vcsRefState.ts delete mode 100644 packages/client-runtime/src/vcsStatusState.test.ts delete mode 100644 packages/client-runtime/src/vcsStatusState.ts delete mode 100644 packages/client-runtime/src/wsRpcClient.test.ts delete mode 100644 packages/client-runtime/src/wsRpcClient.ts delete mode 100644 packages/client-runtime/src/wsRpcProtocol.ts delete mode 100644 packages/client-runtime/src/wsTransport.test.ts delete mode 100644 packages/client-runtime/src/wsTransport.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f0eef0c1e..eaf8fc367cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,52 +60,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/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts new file mode 100644 index 00000000000..c2bd8776e67 --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -0,0 +1,297 @@ +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.ElectronSafeStorageShape); +} + +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.DesktopConnectionCatalogStoreReadError, + ); + 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}/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.cause, permissionError); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + 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..0b382bb163c --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -0,0 +1,328 @@ +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 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 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, +); + +export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( + "DesktopConnectionCatalogStoreWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop connection catalog: ${this.cause.message}`; + } +} + +export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( + "DesktopConnectionCatalogStoreDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode the desktop connection catalog."; + } +} + +export class DesktopConnectionCatalogStoreReadError extends Data.TaggedError( + "DesktopConnectionCatalogStoreReadError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to read desktop connection catalog: ${this.cause.message}`; + } +} + +export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError( + "DesktopConnectionCatalogStoreMigrationError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Failed to migrate legacy desktop saved environments."; + } +} + +export interface DesktopConnectionCatalogStoreShape { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDecodeError + | DesktopConnectionCatalogStoreMigrationError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + | DesktopConnectionCatalogStoreWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError + >; + readonly clear: Effect.Effect; +} + +export class DesktopConnectionCatalogStore extends Context.Service< + DesktopConnectionCatalogStore, + DesktopConnectionCatalogStoreShape +>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })), + ); +} + +const readDocument = ( + fileSystem: FileSystem.FileSystem, + catalogPath: string, +): Effect.Effect< + Option.Option, + PlatformError.PlatformError | Schema.SchemaError +> => + fileSystem.readFileString(catalogPath).pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), + ), + Effect.flatMap((raw) => + raw === null + ? Effect.succeed(Option.none()) + : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(Effect.map(Option.some)), + ), + ); + +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); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* Effect.gen(function* () { + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.catalogPath); + }).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.DesktopSavedEnvironmentsShape, +): Effect.fn.Return< + RuntimeConnectionCatalogDocumentType, + DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError +> { + 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); + if (Option.isSome(token)) { + credentials.push({ + connectionId: id, + credential: new BearerConnectionCredential({ token: token.value }), + }); + } + } + + return { + schemaVersion: 1, + targets, + profiles, + credentials, + remoteDpopTokens: [], + }; +}); + +export const layer = Layer.effect( + DesktopConnectionCatalogStore, + 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) => new DesktopConnectionCatalogStoreWriteError({ cause })), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + catalogPath, + document: { version: 1, encryptedCatalog }, + suffix, + }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause }))); + }); + + const migrateLegacyCatalog = Effect.gen(function* () { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + const records = yield* savedEnvironments.getRegistry; + if (records.length === 0) { + return Option.none(); + } + const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments); + const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog); + yield* writeCatalog(encoded); + return Option.some(encoded); + }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause }))); + + return DesktopConnectionCatalogStore.of({ + get: Effect.gen(function* () { + const document = yield* readDocument(fileSystem, catalogPath).pipe( + Effect.mapError((cause) => new DesktopConnectionCatalogStoreReadError({ cause })), + ); + if (Option.isNone(document)) { + return yield* migrateLegacyCatalog; + } + if (!(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + const decrypted = yield* decodeSecretBytes(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"), + ), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index c7b46265887..85313370547 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,9 +1,9 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; +import * as Electron from "electron"; + import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; - -import * as Electron from "electron"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( "ElectronSafeStorageAvailabilityError", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a6c8428efa9..1a9d16380a4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -10,12 +10,10 @@ import { } 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, @@ -59,11 +57,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" 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); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c5dabe0930f..e270ef404bb 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -20,11 +20,9 @@ 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_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/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts index a5a7aacff79..9f6a964ac05 100644 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -59,7 +59,7 @@ function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInpu 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), + HttpClientRequest.setHeaders(Object.fromEntries(headers.entries())), input.body === undefined ? identity : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts new file mode 100644 index 00000000000..c779c554ffd --- /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 { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getConnectionCatalog = 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 = 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 = 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/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/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 6eeaa3202d9..2f46b263b0f 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, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ed0b9b5cf0..33eac8ea646 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,6 +29,7 @@ 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 DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -117,7 +118,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopLifecycle.layerShutdown, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopSavedEnvironments.layer, + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopCloudAuthTokenStore.layer, DesktopAssets.layer, DesktopObservability.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ce12f19bf72..35c34d39c9e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -42,16 +42,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { 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( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..3456d7b7f3f 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -289,7 +289,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,10 +298,15 @@ 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.DesktopSavedEnvironmentsReadError, ); + const secretError = yield* savedEnvironments + .getSecret(savedRegistryRecord.environmentId) + .pipe(Effect.flip); + assert.instanceOf(secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); }), ), ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 531b50ba73b..137a9a31dad 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -82,6 +82,16 @@ export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( } } +export class DesktopSavedEnvironmentsReadError extends Data.TaggedError( + "DesktopSavedEnvironmentsReadError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to read desktop saved environments: ${this.cause.message}`; + } +} + export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( "DesktopSavedEnvironmentSecretDecodeError", )<{ @@ -93,6 +103,7 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( } export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentsReadError | DesktopSavedEnvironmentSecretDecodeError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageDecryptError; @@ -103,7 +114,10 @@ export type DesktopSavedEnvironmentsSetSecretError = | ElectronSafeStorage.ElectronSafeStorageEncryptError; export interface DesktopSavedEnvironmentsShape { - readonly getRegistry: Effect.Effect; + readonly getRegistry: Effect.Effect< + readonly PersistedSavedEnvironmentRecord[], + DesktopSavedEnvironmentsReadError + >; readonly setRegistry: ( records: readonly PersistedSavedEnvironmentRecord[], ) => Effect.Effect; @@ -176,18 +190,20 @@ function normalizeSavedEnvironmentRegistryDocument( function readRegistryDocument( fileSystem: FileSystem.FileSystem, registryPath: string, -): Effect.Effect { +): Effect.Effect< + SavedEnvironmentRegistryDocument, + PlatformError.PlatformError | Schema.SchemaError +> { 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(error), + ), + Effect.flatMap((raw) => + raw === null + ? Effect.succeed({ version: 1, records: [] }) + : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( Effect.map(normalizeSavedEnvironmentRegistryDocument), - Effect.orElseSucceed(() => ({ version: 1, records: [] })), ), - }), ), ); } @@ -267,13 +283,14 @@ export const layer = Layer.effect( Effect.map((document) => document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), ), + Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })), Effect.withSpan("desktop.savedEnvironments.getRegistry"), ), setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { const currentDocument = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, - ); + ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); yield* writeDocument(preserveExistingSecrets(currentDocument, records)); }), getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { @@ -281,7 +298,7 @@ export const layer = Layer.effect( const document = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, - ); + ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause }))); const encoded = Option.fromNullishOr( document.records.find((record) => record.environmentId === environmentId) ?.encryptedBearerToken, @@ -299,7 +316,7 @@ export const layer = Layer.effect( const document = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, - ); + ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); if (!(yield* safeStorage.isEncryptionAvailable)) { return false; @@ -331,7 +348,7 @@ export const layer = Layer.effect( const document = yield* readRegistryDocument( fileSystem, environment.savedEnvironmentRegistryPath, - ); + ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); if ( !document.records.some( (record) => 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/package.json b/apps/mobile/package.json index f0a4dc2a905..0c853aaec73 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1583fdbb2d7..db44e9904f8 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"; @@ -42,12 +40,13 @@ function AppNavigator() { } 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 +80,7 @@ function AppNavigatorContent() { sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; - if (isLoadingSavedConnection) { + if (state.isLoadingConnections) { return ; } @@ -129,8 +128,6 @@ export default function RootLayout() { DMSans_500Medium, DMSans_700Bold, }); - useRemoteEnvironmentBootstrap(); - return ( diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx index 566c038cc24..cf1f6a7f7e5 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]); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index c2b94dd9097..3a846a13053 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -2,17 +2,20 @@ import { Stack, useRouter } from "expo-router"; import { useState } from "react"; import { Text as RNText, View } from "react-native"; +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"; /* ─── 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(""); @@ -32,9 +35,13 @@ export default function HomeRouteScreen() { headerTitle: "", headerSearchBarOptions: { placeholder: "Search threads", + hideNavigationBar: false, onChangeText: (event) => { setSearchQuery(event.nativeEvent.text); }, + onCancelButtonPress: () => { + setSearchQuery(""); + }, allowToolbarIntegration: true, }, }} @@ -104,6 +111,7 @@ export default function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} onAddConnection={() => router.push("/connections/new")} + onOpenEnvironments={() => router.push("/settings/environments")} onSelectThread={(thread) => { router.push(buildThreadRoutePath(thread)); }} 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..dbde9c4a412 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"); @@ -183,15 +190,10 @@ export default function NewTaskRoute() { diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b..93a4194d83e 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) => ( )} - {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 ( @@ -164,11 +163,13 @@ function ConfiguredCloudEnvironmentRows() { 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,33 +177,48 @@ 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} ) : ( @@ -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..856264d602e 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 ( @@ -70,7 +77,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 +94,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 +116,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 +196,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 +267,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; } @@ -382,15 +432,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} + (); /* ─── 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/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts new file mode 100644 index 00000000000..0682b25ae38 --- /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", + message: `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..580341941a6 --- /dev/null +++ b/apps/mobile/src/connection/platform.ts @@ -0,0 +1,200 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + ConnectionWakeups, + Connectivity, +} 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 = Layer.succeed( + Connectivity, + Connectivity.of({ + 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 = Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + 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", + message: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + message: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }), + ).pipe( + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + message: `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", + message: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + message: "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, + }), + ), + ), + }), +); + +export const connectionPlatformLayer = 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..3b1eade0818 --- /dev/null +++ b/apps/mobile/src/connection/runtime.ts @@ -0,0 +1,16 @@ +import { connectionLayer as clientConnectionLayer } 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), +); + +export const connectionLayer = clientConnectionLayer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime = 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..5754d655633 --- /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 { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionCredentialStore, + ConnectionProfileStore, + ConnectionTransientError, +} 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", + message: `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 = ConnectionProfileStore.of({ + 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 = ConnectionCredentialStore.of({ + 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 = RemoteDpopAccessTokenStore.of({ + 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(ConnectionProfileStore, profileStore), + Context.add(ConnectionCredentialStore, credentialStore), + Context.add(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..ec50e4ae9ce 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 { ManagedRelayClient } 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(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..a522129d40d 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; 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..257b914fe97 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 { type ManagedRelayClient } 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..24e6a094661 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 { ManagedRelayClient } 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,20 +174,41 @@ const relayToken = Effect.gen(function* () { function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, + 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; + logRegistrationDebug("relay device registration request started", { + expectedGeneration, + }); yield* client.registerDevice({ clerkToken: token, payload: body, }); + logRegistrationDebug("relay device registration request completed", { + expectedGeneration, + }); }); } @@ -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 { @@ -230,20 +277,107 @@ function runRegistrationInBackground( 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,6 +404,7 @@ function registerDevice(input?: { notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, }), + expectedGeneration, ); }); } @@ -277,10 +416,7 @@ function registerDeviceForCurrentUser( } 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", @@ -372,6 +508,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { } relayTokenProvider = null; relayTokenProviderIdentity = null; + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; } export function unregisterAgentAwarenessDeviceForCurrentUser( 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..b8349fc60d3 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 { ManagedRelayClient, 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( + 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/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..aa1071fd3c2 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -8,8 +8,8 @@ import { managedRelayClientLayer, ManagedRelayClient, ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { @@ -55,6 +55,8 @@ 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}`), @@ -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..680e6e80cfa 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -16,22 +16,23 @@ 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 } from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, ManagedRelayClient, + type ManagedRelayClientError, ManagedRelayDpopSigner, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; +import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -56,6 +57,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 +66,6 @@ export interface CloudEnvironmentRecordWithStatus { readonly statusError: string | null; } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -82,11 +83,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 +151,22 @@ 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: ManagedRelayClientError) => { + const relayError = cause.relayError; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(cause.traceId ? { traceId: cause.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; @@ -462,23 +456,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* ManagedRelayClient; + + const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -528,7 +525,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 +545,9 @@ function connectRelayManagedEnvironment(input: { authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, relayManaged: true, - }; - }); -} + } satisfies SavedRemoteConnection; + }, +); export function connectCloudEnvironment(input: { readonly clerkToken: string; diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 0de43d049c5..6678d13047e 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,42 +1,46 @@ import { - managedRelayClientLayer, + managedRelayClientLayer as makeManagedRelayClientLayer, ManagedRelayDpopSigner, ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +} 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( +const relayDpopSignerLayer = Layer.effect( ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const loadProofKey = yield* Effect.cached( + loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.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 })), - ), + thumbprint: loadProofKey.pipe( + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - Effect.gen(function* () { - const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - ); + createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")( + function* (input) { + const proofKey = yield* loadProofKey; return yield* createDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), ); - }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), + }, + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + ), }); }), ); -export const mobileManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( - Layer.provideMerge(mobileRelayDpopSignerLayer), - ); +export const managedRelayClientLayer = (relayUrl: string) => + makeManagedRelayClientLayer({ + 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..54153a426a1 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -0,0 +1,107 @@ +import { + type ManagedRelayAccessTokenCacheEntry, + type ManagedRelayAccessTokenStore, +} 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: 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..0307fcdab30 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -94,9 +94,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 +106,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..2d304da7c02 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -70,13 +70,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 && diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index dd26e2e6ffb..5c3e290fa22 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,10 +71,7 @@ export function ConnectionEnvironmentRow(props: { > @@ -82,16 +86,35 @@ export function ConnectionEnvironmentRow(props: { {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} 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..15852cc3c88 --- /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/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/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cdae41668a0..b79d299f0cc 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,11 +1,11 @@ -import type { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - VcsStatusState, -} from "@t3tools/client-runtime"; +import { + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -13,29 +13,29 @@ 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"; /* ─── 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 onAddConnection: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onOpenEnvironments: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; } interface ProjectGroup { readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; } const projectGroupActivityOrder = Order.mapInput( @@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput( /* ─── 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" }; @@ -67,11 +67,11 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s const COLLAPSED_THREAD_LIMIT = 6; 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 +79,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 +87,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 +132,8 @@ function deriveEmptyState(props: { /* ─── Project group header ───────────────────────────────────────────── */ function ProjectGroupLabel(props: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; readonly totalThreadCount: number; - readonly httpBaseUrl: string | null; - readonly bearerToken: string | null; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { @@ -139,11 +142,10 @@ function ProjectGroupLabel(props: { return ( { - 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 isLast: boolean; }) { @@ -196,15 +183,9 @@ function ThreadRow(props: { const tone = threadStatusTone(props.thread); const timestamp = relativeTime(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), + ); return ( ({ opacity: pressed ? 0.7 : 1 })}> @@ -262,8 +243,8 @@ function ThreadRow(props: { - {/* Branch + git info */} - {branch ? ( + {/* Environment + branch */} + {subtitleParts.length > 0 ? ( - {branch} + {subtitleParts.join(" · ")} - {gitParts.length > 0 ? ( - - {" · " + gitParts.join(" · ")} - - ) : null} ) : null} @@ -293,8 +269,61 @@ function ThreadRow(props: { /* ─── 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 insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const toggleExpanded = useCallback((key: string) => { @@ -328,7 +357,7 @@ export function HomeScreen(props: HomeScreenProps) { /* Group filtered threads by project */ const projectGroups = useMemo>(() => { - const byProject = new Map(); + const byProject = new Map(); for (const thread of filteredThreads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); const existing = byProject.get(key); @@ -351,77 +380,93 @@ export function HomeScreen(props: HomeScreenProps) { /* Empty states */ const hasAnyThreads = props.threads.length > 0; const hasResults = filteredThreads.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 ? ( - - + + {!hasAnyThreads ? ( + + + {emptyState.loading ? ( + + + + ) : null} + + ) : !hasResults ? ( + + ) : ( + projectGroups.map((group) => { + const isExpanded = expandedProjects.has(group.key); + const visibleThreads = isExpanded + ? group.threads + : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + + return ( + + toggleExpanded(group.key)} + /> + + {visibleThreads.map((thread, i) => ( + props.onSelectThread(thread)} + isLast={i === visibleThreads.length - 1} + /> + ))} + + + ); + }) + )} + + {shouldShowConnectionStatus ? ( + + - {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)} - /> - - {visibleThreads.map((thread, i) => ( - props.onSelectThread(thread)} - isLast={i === visibleThreads.length - 1} - /> - ))} - - - ); - }) - )} - + ) : null} + ); } 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..11ff7288d61 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; @@ -224,12 +228,12 @@ function ProjectPathInput(props: { } function useEnvironmentOptions(): ReadonlyArray { - 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, @@ -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,12 +557,9 @@ 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 ( @@ -593,7 +609,14 @@ function FolderBrowser(props: { () => (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,20 +783,23 @@ 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 ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index c82ca71596a..c02120b2619 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -16,8 +16,12 @@ 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 { useThreadDraftForThread } from "../../state/use-thread-composer-state"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useReviewCacheForThread } from "./reviewState"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { @@ -29,6 +33,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; @@ -114,6 +119,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 +134,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 +200,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[] = []; @@ -312,34 +336,51 @@ 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" ? ( { + 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/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/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 71643336d54..b29a72c54b4 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) { diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e4ac3cc5c8b..b0361f05575 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,19 @@ 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 { - 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 +41,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 +154,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 +174,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 +191,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 +241,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 +258,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 +401,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 +497,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 +672,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 +739,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 +757,7 @@ export function ThreadTerminalRouteScreen() { readyBufferReplayKey, routeEnvironmentId, routeThreadId, + resizeTerminal, scheduleBufferReplayReady, selectedThread, terminalId, @@ -855,17 +811,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 +859,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 +891,10 @@ export function ThreadTerminalRouteScreen() { ); } + if (!environment.isReady && environment.presentation === null) { + return ; + } + return ( <> - - - - {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/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/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index de95e3645bd..8a93989f3d9 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,16 @@ 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 { 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 +52,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 +162,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 +213,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 +274,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 +306,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 +376,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) { diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index eb9e929ed14..79a01cdada3 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -10,7 +10,7 @@ export interface PendingApprovalCardProps { readonly onRespond: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; } export function PendingApprovalCard(props: PendingApprovalCardProps) { diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c42a7ff34e0..5bd0d4e4a3c 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -20,7 +20,7 @@ export interface PendingUserInputCardProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmit: () => Promise; + readonly onSubmit: () => Promise; } 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..edac061daec 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"; @@ -43,11 +50,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 +76,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 +89,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 +137,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 +211,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 +239,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 +264,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 +446,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 +466,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); - void onUpdateInteractionMode(item.command); + onUpdateInteractionMode(item.command); return; } @@ -452,14 +505,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; - const configurationLabel = useMemo(() => { - const parts = [ - formatTitleCase(currentEffort), - currentFastMode ? "Fast" : null, - currentContextWindow !== "1M" ? currentContextWindow : null, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "Configuration"; - }, [currentContextWindow, currentEffort, currentFastMode]); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: currentModelOption?.capabilities, + selections: currentModelSelection.options, + }), + [currentModelOption?.capabilities, currentModelSelection.options], + ); + const configurationLabel = useMemo( + () => providerOptionsConfigurationLabel(providerOptionDescriptors), + [providerOptionDescriptors], + ); const modelMenuActions = useMemo( () => providerGroups.map((group) => ({ @@ -486,36 +543,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Options menu ───────────────────────────────────────── const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: currentEffort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: currentFastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: currentFastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: currentContextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: currentContextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -555,13 +583,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), }, ], - [ - currentEffort, - currentFastMode, - currentContextWindow, - currentRuntimeMode, - currentInteractionMode, - ], + [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], ); // ── Menu handlers ──────────────────────────────────────── @@ -572,51 +594,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const modelKey = event.slice("model:".length); const option = modelOptions.find((o) => o.key === modelKey); if (option) { - void props.onUpdateModelSelection(option.selection); + props.onUpdateModelSelection(option.selection); } } function handleOptionsMenuAction(event: string) { - if (event.startsWith("options:effort:")) { - const effort = event.slice("options:effort:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption( - currentModelSelection, - "effort", - effort as typeof currentEffort, - ) - : currentModelSelection; - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:fast-mode:")) { - const fastMode = event.endsWith(":on"); - const nextFast = fastMode || undefined; - if (selectedProviderDriver === "opencode") { - return; - } - const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:context-window:")) { - const contextWindow = event.slice("options:context-window:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) - : currentModelSelection; - void props.onUpdateModelSelection(updated); + const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); + if (providerOptions) { + props.onUpdateModelSelection({ + ...currentModelSelection, + options: providerOptions, + }); return; } if (event.startsWith("options:runtime:")) { const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; - void props.onUpdateRuntimeMode(runtimeMode); + props.onUpdateRuntimeMode(runtimeMode); return; } if (event.startsWith("options:interaction:")) { const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; - void props.onUpdateInteractionMode(interactionMode); + props.onUpdateInteractionMode(interactionMode); } } @@ -652,6 +650,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + void props.onStopThread()} - /> + ) : ( void props.onStopThread()} + onPress={props.onStopThread} showChevron={false} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d035f6eb909..fbfde4e787a 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,29 @@ 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 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 +91,7 @@ export interface ThreadDetailScreenProps { questionId: string, customAnswer: string, ) => void; - readonly onSubmitUserInput: () => Promise; + readonly onSubmitUserInput: () => Promise; readonly showContent?: boolean; } @@ -306,10 +307,10 @@ 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 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, + }); + + if (uri === null) { + return ( + + + + ); + } + + return ( + props.onPressImage(uri)}> + + + ); +} + function stripShellWrapper(value: string): string { const trimmed = value.trim(); const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); @@ -654,7 +681,7 @@ function useMarkdownStyles(): MarkdownStyleSets { function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; @@ -733,26 +760,14 @@ function renderFeedEntry( /> ) : 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} + /> ); })} @@ -801,27 +816,14 @@ function renderFeedEntry( ) ) : null} {attachments.map((attachment) => { - const uri = messageImageUrl(props.httpBaseUrl, attachment.id); - if (!uri) { - return null; - } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + environmentId={props.environmentId} + attachmentId={attachment.id} + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + onPressImage={props.onPressImage} + /> ); })} {showAssistantMeta ? ( @@ -1220,6 +1222,37 @@ function compactFileName(filePath: string): string { return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } +function ThreadFeedPlaceholder(props: { + readonly bottomInset: number; + readonly detail: string; + readonly horizontalPadding: number; + readonly loading?: boolean; + readonly title: string; + readonly topInset: number; +}) { + return ( + + + {props.loading ? : null} + {props.title} + + {props.detail} + + + + ); +} + export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); @@ -1446,9 +1479,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, @@ -1481,30 +1513,45 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { 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..a66f2082171 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -9,7 +9,7 @@ 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"; diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 84ae71dce5c..77a80fff550 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); @@ -186,76 +169,116 @@ 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..6d0bd2307ac 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,14 +1,20 @@ 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"; @@ -16,13 +22,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r import { scopedThreadKey } from "../../lib/scopedEntities"; 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 +44,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 +66,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 +100,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 +111,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 +132,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 +156,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 +326,7 @@ export function ThreadRouteScreen() { if (!selectedThread) { const stillHydrating = - isLoadingSavedConnection || + workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; @@ -265,19 +353,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, @@ -313,7 +396,7 @@ export function ThreadRouteScreen() { 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..3fbea89ba32 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; diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 478e2642035..9e20f5b1560 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; diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 65e0488622e..3d196715284 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -1,4 +1,4 @@ -import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime"; +import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/state/vcs"; import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index a940fcdfcc3..314d0cfcd20 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -3,7 +3,7 @@ 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"; @@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; 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 +37,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; 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/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..a0c19d9fe8b 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -1,67 +1,26 @@ 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"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -73,13 +32,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 +47,76 @@ 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; + const error = new Error("Enter a task before starting the thread."); + setPendingConnectionError(error.message); + return AsyncResult.failure(Cause.fail(error)); } if (input.envMode === "worktree" && !input.branch) { - return null; + const error = new Error("Select a base branch before creating a worktree."); + setPendingConnectionError(error.message); + return AsyncResult.failure(Cause.fail(error)); } 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, + }, + 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, + } + : {}), }, - ...(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/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/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.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..56d5663212c 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; diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index ce37a41e8ab..bb8c1e8398a 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,25 +1,29 @@ 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))), - ), +export 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.make(runtimeLayer); + +export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 83ff2db5748..c3dd28ac3a1 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(), }, })); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 2f9e4962c1a..da54f92949b 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,9 +1,7 @@ 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,29 +12,12 @@ 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; -} -export interface MobilePreferences { +export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } -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); } @@ -58,77 +39,6 @@ async function readJsonStorageItem(key: string): Promise { } } -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; -} - -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 { - 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. - } -} - -export async function clearCachedShellSnapshot(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) { - file.delete(); - } - } catch { - // Ignore cache cleanup failures. - } -} - export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; @@ -169,8 +79,8 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ 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,11 +100,9 @@ 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, }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e5fdb439954..d6daa01d044 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; 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..9eec5dc1250 --- /dev/null +++ b/apps/mobile/src/state/entities.ts @@ -0,0 +1,59 @@ +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 } from "./server"; +import { environmentSession } from "./session"; +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 + : environmentSession.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..83d1fdce462 --- /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 { environmentSession } from "./session"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + configValueAtom: environmentSession.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..f72cc96e54a --- /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.configValueAtom, +}); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + configValueAtom: 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..ab1fea9840d 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; @@ -30,7 +31,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 { @@ -79,20 +80,24 @@ async function loadPersistedComposerDrafts(): Promise): Promise { + const file = await getComposerDraftsFile(); + const nonEmptyDrafts = Object.fromEntries( + Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); + const document: PersistedComposerDrafts = { + schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, + drafts: nonEmptyDrafts, + }; + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(document)); +} + async function savePersistedComposerDrafts(drafts: Record): Promise { try { - const file = await getComposerDraftsFile(); - const nonEmptyDrafts = Object.fromEntries( - Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); - const document: PersistedComposerDrafts = { - schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, - drafts: nonEmptyDrafts, - }; - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(document)); + await writePersistedComposerDrafts(drafts); } catch { // Draft persistence is best-effort; in-memory drafts still keep working. } @@ -109,20 +114,23 @@ 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(() => { + // Draft loading is best-effort; in-memory drafts still keep working. }); - }); } function updateComposerDrafts( @@ -234,6 +242,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..d7b66751d04 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,10 +1,8 @@ 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 { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; -import { Atom } from "effect/unstable/reactivity"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -14,7 +12,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 +24,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 +62,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 +83,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 +99,7 @@ export function useThreadComposerState() { const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThreadSessionActivity = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } @@ -217,10 +108,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 +122,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 +148,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( 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/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 6abd8f48e61..9d431140d06 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -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(); @@ -120,6 +122,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 +134,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/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..6e1be00209d 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"; @@ -177,6 +179,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( ...session, scopes: new Set(session.scopes), }), + session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); }), @@ -289,6 +292,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, + traceRelayRequest, Effect.catchTags({ ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 9ce33deaaef..02c4b0d09ac 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -10,7 +10,10 @@ 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 { + classifyRelayClientOutput, + makeCloudManagedEndpointRuntime, +} from "./ManagedEndpointRuntime.ts"; const relayClientAvailableLayer = Layer.succeed( RelayClient.RelayClient, @@ -57,6 +60,20 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + it("classifies Cloudflare connection and warning output", () => { + expect( + classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", + ), + ).toBe("connected"); + expect( + classifyRelayClientOutput("2026-06-17T02:00:00Z ERR Failed to serve tunnel connection"), + ).toBe("warning"); + expect(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 = []; @@ -113,8 +130,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]); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 73e549ebf49..7c8735b12e0 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -9,6 +9,7 @@ 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 * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -68,6 +69,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, @@ -141,6 +149,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 +231,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 +274,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", diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 8ea7ca06f9a..78285eb7dcd 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -11,15 +11,12 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { - consumeCloudReplayGuards, - reconcileDesiredCloudLink, - traceRelayBrokerHandler, -} from "./http.ts"; +import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; import { CloudManagedEndpointRuntime, type CloudManagedEndpointRuntimeShape, } from "./ManagedEndpointRuntime.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStoreError({ @@ -75,8 +72,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 +121,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)), ); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index b78d47a20c1..89928ae13a2 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -48,7 +48,7 @@ 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"; @@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts"; import * as CliState 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-"; @@ -111,19 +112,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); } @@ -953,7 +941,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/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/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 99121182713..94f3ee5b435 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -47,7 +47,11 @@ import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScript import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; -import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; +import { + GitVcsDriver, + type GitRemoteStatusOptions, + type GitStatusDetails, +} from "../vcs/GitVcsDriver.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -69,6 +73,7 @@ export interface GitManagerShape { ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, + options?: GitRemoteStatusOptions, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; @@ -745,9 +750,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?: 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 +786,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), }); @@ -1355,8 +1363,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return yield* Cache.get(localStatusResultCache, cacheKey); }); const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( - function* (input) { + function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + if (options?.refreshUpstream === false) { + return yield* readRemoteStatus(cacheKey, options); + } return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..5fce28922fd 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -29,7 +29,7 @@ import { } from "@t3tools/contracts"; import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; -import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { GitVcsDriver, type GitRemoteStatusOptions } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; export interface GitWorkflowServiceShape { @@ -41,6 +41,7 @@ export interface GitWorkflowServiceShape { ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, + options?: GitRemoteStatusOptions, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; @@ -259,10 +260,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, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 5197ad34296..37baff432fe 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -32,6 +32,7 @@ import { } from "./assets/AssetAccess.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, failEnvironmentScopeRequired, @@ -100,7 +101,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.metadata.descriptor")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); return yield* serverEnvironment.getDescriptor; - }), + }, traceRelayRequest), ); }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 77f9a2ed904..a08da26ba59 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -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 => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9e0ad364d97..6bdf62b104f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -97,6 +97,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"; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d02c83d563e..280f61bcb20 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -100,6 +100,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."; @@ -304,7 +314,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; @@ -423,7 +433,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 +454,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")( 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) => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 42a692c5394..1da0ea27a65 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -126,7 +126,7 @@ const HttpServerLive = Layer.unwrap( ); 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, }); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8b5aa3adbcd..a55e5244893 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -319,6 +319,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 +503,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/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index e33d9b4b290..6d528f02aa9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -5,6 +5,7 @@ import { type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, + type TerminalResizeInput, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, @@ -2325,22 +2326,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith 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, - }); + 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; } - session.cols = input.cols; - session.rows = input.rows; - session.updatedAt = yield* nowIso; + 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: TerminalManagerShape["resize"] = (input) => + withThreadLock(input.threadId, resizeLocked(input)); + const clear: TerminalManagerShape["clear"] = (input) => withThreadLock( input.threadId, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..66a5157ae83 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -168,6 +168,10 @@ export interface GitSetBranchUpstreamInput { remoteBranch: string; } +export interface GitRemoteStatusOptions { + readonly refreshUpstream?: boolean; +} + export interface GitVcsDriverShape { readonly execute: (input: ExecuteGitInput) => Effect.Effect; readonly status: (input: VcsStatusInput) => Effect.Effect; @@ -175,6 +179,7 @@ export interface GitVcsDriverShape { readonly statusDetailsLocal: (cwd: string) => Effect.Effect; readonly statusDetailsRemote: ( cwd: string, + options?: GitRemoteStatusOptions, ) => Effect.Effect; readonly prepareCommitContext: ( cwd: string, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 173d7649bd1..f4b2fe4d914 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -183,6 +183,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(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a763026c23f..69550e0e7e5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1444,11 +1444,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusDetailsRemote: GitVcsDriver.GitVcsDriverShape["statusDetailsRemote"] = Effect.fn( "statusDetailsRemote", - )(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); + )(function* (cwd, options) { + if (options?.refreshUpstream !== false) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + } return yield* readStatusDetailsRemote(cwd); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 7c5768162a9..d78999f88c1 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: () => @@ -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", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d83dc26fbed..f0cacab2dcb 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -275,9 +275,12 @@ export const layer = Layer.effect( const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, + options?: { readonly refreshUpstream?: boolean }, ) { - yield* workflow.invalidateRemoteStatus(cwd); - const remote = yield* workflow.remoteStatus({ cwd }); + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }); @@ -303,17 +306,22 @@ export const layer = Layer.effect( ) => { 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; - if (Duration.isZero(configuredInterval)) { + const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { return activeInterval; } - const exit = yield* refreshRemoteStatus(cwd).pipe(Effect.exit); + 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; } diff --git a/apps/web/package.json b/apps/web/package.json index d6751c73486..13973b18874 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,9 +8,7 @@ "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", @@ -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/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/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..856654323c9 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -7,7 +7,7 @@ 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 { usePreviewWebviewConfig } from "./previewWebviewConfigState"; @@ -36,7 +36,7 @@ export function HostedBrowserWebview(props: { const initialSrcRef = useRef(initialUrl ?? "about:blank"); 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 }); 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/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/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/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/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 30cb596781a..b4a347fca9b 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,911 +1,323 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { + 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 { + AVAILABLE_CONNECTION_STATE, + type EnvironmentRegistryService, + EnvironmentSupervisor, + type EnvironmentSupervisorService, + type PreparedConnection, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { type RpcSession } from "@t3tools/client-runtime/rpc"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { managedRelayClientLayer, ManagedRelayClient, ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -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( +vi.mock("./relayClientInstallDialog", () => ({ + requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation, + reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress, + finishRelayClientInstall: relayClientInstallDialog.finish, +})); + +const createProof = vi.fn(() => Effect.succeed("dpop-proof")); +const dpopSignerLayer = Layer.succeed( ManagedRelayDpopSigner, ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("web-thumbprint"), - createProof: (input) => createProofMock(input), + thumbprint: Effect.succeed("thumbprint"), + createProof, }), ); -function cloudClientLayer() { - const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); +function relayLayer() { + const http = remoteHttpClientLayer(globalThis.fetch); return Layer.mergeAll( - httpClientLayer, + http, managedRelayClientLayer({ 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 EnvironmentSupervisorService); + 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 EnvironmentRegistryService; + 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, + 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(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); - it("normalizes configured relay base URLs before building relay requests", () => { +describe("web cloud link environment client", () => { + it("normalizes relay URLs and de-duplicates cloud link targets", () => { expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect( + collectCloudLinkTargets({ + primary: TARGET, + saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], + }).map((target) => target.environmentId), + ).toEqual(["environment-1", "environment-2"]); }); - it.effect( - "installs the relay client over environment RPC before requesting a cloud challenge", - () => - Effect.gen(function* () { - getRelayClientStatusMock.mockResolvedValue({ - status: "missing", - version: "2026.5.2", - }); - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json({ malformed: true })); - vi.stubGlobal("fetch", fetchMock); - installRelayClientMock.mockImplementationOnce(async (onProgress) => { - onProgress({ type: "progress", stage: "downloading" }); - return availableRelayClient(); - }); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - - expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( - "2026.5.2", - ); - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(installRelayClientMock).toHaveBeenCalledOnce(); - expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ - type: "progress", - stage: "downloading", - }); - expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); - expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( - fetchMock.mock.invocationCallOrder[0]!, - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - }), - ); - - it.effect("lists relay-managed environments for hosted and served web clients", () => + it.effect("lists relay-managed environments through the typed relay client", () => Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ environments: [ { - environmentId: "env-1", - label: "Managed desktop", + environmentId: "environment-1", + label: "Desktop", endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, - linkedAt: "2026-05-25T00:00:00.000Z", + linkedAt: "2026-06-06T00:00:00.000Z", }, ], }), ); vi.stubGlobal("fetch", fetchMock); - const environments = yield* withCloudServices( + const environments = yield* withServices( listManagedCloudEnvironments({ clerkToken: "clerk-token" }), ); + expect(environments).toHaveLength(1); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/environments", - ); expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - }), - ); - - it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => - Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "relay-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "environment:connect", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - endpoint: environment.endpoint, - credential: "environment-bootstrap", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - label: "Managed desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ) - .mockResolvedValueOnce( - Response.json({ - access_token: "environment-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const connection = yield* withCloudServices( - connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), - ); - expect(connection).toMatchObject({ - environmentId: "env-1", - accessToken: "environment-access-token", - }); - - const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); - expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); - expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); - expect(createProofMock).toHaveBeenCalledWith({ - method: "POST", - url: "https://managed.example.test/oauth/token", - }); - const traceparents = fetchMock.mock.calls.map( - (call) => call[1]?.headers.traceparent as string | undefined, - ); - expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); - expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); - expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( - traceparents[0]?.split("-")[1], - ); - }), - ); - - it.effect("rejects a stored managed connection for another relay origin", () => - 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", - }), - ).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", - }), - ), - ); - - 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", () => + it.effect("reads primary cloud link state from the explicit target", () => 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 error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).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", - }), - ), - ); - - 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", () => - 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", - }); - }), - ); - - 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); + const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - 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, }), ); - - 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(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-state", ); - expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-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..360ef6d3626 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,36 +13,23 @@ 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 { ManagedRelayClient, type ManagedRelayClientError } 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 { resolveCloudPublicConfig } from "./publicConfig"; @@ -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,22 @@ 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: ManagedRelayClientError) => { + const relayError = cause.relayError; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(cause.traceId ? { traceId: cause.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 +233,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; @@ -336,130 +320,11 @@ export function listCloudDevices(input: { }); } -export function connectManagedCloudEnvironment(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; - readonly relayUrl?: string; -}): Effect.Effect< - CloudManagedConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); - if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "The saved environment is linked through a different configured relay.", - }); - } - const relayClient = yield* ManagedRelayClient; - const connected = yield* relayClient - .connectEnvironment({ - clerkToken: input.clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: input.environment.environmentId, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not connect to relay-managed environment.", - cause, - }), - ), - ); - if (connected.environmentId !== input.environment.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if ( - connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || - connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || - connected.endpoint.providerKind !== input.environment.endpoint.providerKind - ) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", - }); - } - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not read connected environment descriptor.", - cause, - }), - ), - ); - if (descriptor.environmentId !== connected.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint does not match the selected environment.", - }); - } - const signer = yield* ManagedRelayDpopSigner; - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not create environment DPoP proof.", - cause, - }), - ), - ); - const session = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - credential: connected.credential, - dpopProof: bootstrapProof, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not authorize managed environment.", - cause, - }), - ), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: connected.endpoint.httpBaseUrl, - wsBaseUrl: connected.endpoint.wsBaseUrl, - relayUrl: configuredRelayUrl, - accessToken: session.access_token, - 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 -> { +export function readPrimaryCloudLinkState(input: { + readonly target: CloudLinkTarget; +}): Effect.Effect { return Effect.gen(function* () { - if (!readPrimaryCloudLinkTarget()) { - return null; - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) .pipe( @@ -470,10 +335,11 @@ export function readPrimaryCloudLinkState(): Effect.Effect< } 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: {}, @@ -487,16 +353,11 @@ export function updatePrimaryCloudPreferences(input: { } export function unlinkPrimaryEnvironmentFromCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string | null; }): Effect.Effect { 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( @@ -510,7 +371,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(target.environmentId), + environmentId: EnvironmentId.make(input.target.environmentId), }) .pipe( Effect.catch((cause) => @@ -523,115 +384,14 @@ 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."))); - }); -} - export function linkPrimaryEnvironmentToCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient +> { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { @@ -640,14 +400,8 @@ export function linkPrimaryEnvironmentToCloud(input: { }); } 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 environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -672,11 +426,11 @@ 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( @@ -699,7 +453,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ); yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: target.environmentId, + expectedEnvironmentId: input.target.environmentId, expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); 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..a708f6df0e7 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 { ManagedRelayClient, 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,95 @@ 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( + 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..53a3e24c6d8 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,8 @@ import { - managedRelayClientLayer, + managedRelayClientLayer as makeManagedRelayClientLayer, ManagedRelayDpopSigner, ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -17,7 +17,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const webRelayDpopSignerLayer = Layer.effect( +export const relayDpopSignerLayer = Layer.effect( ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -39,24 +39,28 @@ export const webRelayDpopSignerLayer = Layer.effect( return generated; }), ); - const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); + return ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError(signerError), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), + ), + createProof: Effect.fn("web.managedRelayDpopSigner.createProof")( + function* (input) { + const proofKey = yield* loadOrCreateBrowserDpopKey; + return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + ); + }, + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), ), - createProof: (input) => - loadOrCreateBrowserDpopKey.pipe( - Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - Effect.mapError(signerError), - ), }); }), ); -export const webManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( - Layer.provideMerge(webRelayDpopSignerLayer), +export const managedRelayClientLayer = (relayUrl: string) => + makeManagedRelayClientLayer({ 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..0a1ec61a3cc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -4,7 +4,7 @@ import { ManagedRelayClient, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, @@ -13,17 +13,15 @@ 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)), - ), + runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))), ), ); @@ -44,6 +42,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 +58,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -62,6 +69,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 +85,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/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..d9b0989b684 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, @@ -207,8 +206,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 +215,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, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72391f714fc..fd2c2b8c250 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,4 +1,8 @@ -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"; @@ -15,15 +19,15 @@ 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 { 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, @@ -58,8 +62,6 @@ interface BranchToolbarBranchSelectorProps { onComposerFocusRequest?: () => void; } -const EMPTY_REFS: ReadonlyArray = []; - function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -91,6 +93,17 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + 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 +111,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 +123,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 +131,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 +148,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 +187,13 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, hasServerThread, onActiveThreadBranchOverrideChange, - setThreadBranchAction, setDraftThreadContext, draftId, threadRef, environmentId, effectiveEnvMode, + stopThreadSession, + updateThreadMetadata, ], ); @@ -201,7 +204,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 +222,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 +305,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 +344,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 +374,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 +382,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 +430,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 +443,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 +476,6 @@ export function BranchToolbarBranchSelector({ setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); }, []); - useEffect(() => { - if (isBranchMenuOpen) { - return; - } - setShowTopBranchScrollFade(false); - setShowBottomBranchScrollFade(false); - }, [isBranchMenuOpen]); - useLayoutEffect(() => { if (!isBranchMenuOpen) { return; 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..90a6dcdc338 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,15 +40,20 @@ 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 { 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 { @@ -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,7 +99,7 @@ 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"; @@ -99,27 +111,22 @@ import { useRightPanelStore, } from "../rightPanelStore"; import { + applyPreviewServerSnapshot, isPreviewSupportedInRuntime, - selectThreadPreviewState, - usePreviewStateStore, + removePreviewSession, + setActivePreviewTab, + useThreadPreviewState, } from "../previewStateStore"; 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,7 +136,7 @@ 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 { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -138,11 +145,6 @@ 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 +158,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 +165,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 +195,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 +215,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 +234,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 +280,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 +305,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 +355,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 +380,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 +449,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 +463,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, - mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, @@ -568,14 +472,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 +541,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 +587,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), @@ -690,7 +597,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : {}, @@ -712,26 +619,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 +644,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 +676,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 +701,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeNewTerminal, threadId, threadRef, + openTerminal, ]); const activateTerminal = useCallback( @@ -812,31 +714,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 +762,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; @@ -924,41 +831,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 +901,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane summary?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }) : null); @@ -1003,7 +910,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane cwd: terminalCwd, worktreePath: terminalWorktreePath, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }), }); @@ -1018,9 +925,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, @@ -1156,6 +1095,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( @@ -1202,23 +1144,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,13 +1198,15 @@ 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; @@ -1268,35 +1239,32 @@ 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) => + selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen), ); - const rightPanelState = useRightPanelStore((store) => - selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + 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 +1274,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 +1288,67 @@ function ChatViewContent(props: ChatViewProps) { .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); + const planSidebarOpen = activeRightPanelKind === "plan"; + useEffect(() => { if (!activeThreadRef || !diffOpen) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); }, [activeThreadRef, diffOpen]); + + 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 +1363,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 +1381,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,11 +1417,9 @@ function ChatViewContent(props: ChatViewProps) { description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); - } finally { - setReconnectingEnvironmentId(null); } }, - [], + [retryEnvironment], ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const logicalProjectEnvironments = useMemo(() => { @@ -1526,14 +1439,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 +1453,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 +1563,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 +1590,7 @@ 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); + const serverConfig = activeEnvironment?.serverConfig ?? 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 +5013,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 +5048,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..ad84fa72c2d 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 { 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 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, + const options = environments.map((environment): AddProjectEnvironmentOption => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return { + environmentId: environment.environmentId, label: resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: primaryEnvironmentId, - runtimeLabel: primaryEnvironmentLabel, + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, }), - 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, - label: resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: record.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: record.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,28 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); const environmentSettings = - environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId - ? settings - : environmentId - ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings - : null; + environment?.serverConfig?.settings ?? + (environmentId === primaryEnvironmentId ? settings : null); const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + [environments, primaryEnvironmentId, settings], ); 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,69 +562,28 @@ 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( @@ -633,7 +622,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -651,7 +640,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -659,7 +648,7 @@ function OpenCommandPaletteDialog() { await startNewThreadInProjectFromContext( { activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, @@ -867,40 +856,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,7 +954,7 @@ function OpenCommandPaletteDialog() { run: async () => { await startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, @@ -1049,7 +1015,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 +1038,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( @@ -1108,19 +1082,31 @@ 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), { + envMode: settings.defaultThreadEnvMode, + }), + ); + 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 +1115,29 @@ function OpenCommandPaletteDialog() { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - createdAt: new Date().toISOString(), - }); - await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { + }, + }); + 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), { envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); - setOpen(false); - } catch (error) { + }), + ); + if (navigationResult._tag === "Failure") { + const error = squashAtomCommandFailure(navigationResult); toastManager.add( stackedThreadToast({ type: "error", @@ -1143,13 +1145,16 @@ function OpenCommandPaletteDialog() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + setOpen(false); }, [ browseEnvironmentId, browseEnvironmentPlatform, currentProjectCwdForBrowse, handleNewThread, + createProject, navigate, projects, setOpen, @@ -1168,18 +1173,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 +1197,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 +1269,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 +1517,7 @@ function OpenCommandPaletteDialog() { { composerHandleRef?.current?.focusAtEnd(); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 080fa291e7e..7ea2d588477 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,11 @@ +import { useAtomValue } from "@effect/atom-react"; import { Virtualizer } from "@pierre/diffs/react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -19,13 +24,11 @@ import { useRef, useState, } from "react"; -import { openInPreferredEditor } from "../editorPreferences"; +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 { useTheme } from "../hooks/useTheme"; import { @@ -36,8 +39,7 @@ import { resolveFileDiffPath, } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { useProject, useThread } from "../state/entities"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; @@ -45,10 +47,20 @@ import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./Dif import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +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], [data-diff], @@ -161,9 +173,10 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); - const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState>( - () => new Set(), - ); + const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ + scopeKey: null, + fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, + })); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -176,23 +189,32 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff 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 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); @@ -222,6 +244,13 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; + 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"; @@ -304,19 +333,6 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); }, [renderablePatch]); - 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); @@ -342,27 +358,31 @@ 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.", 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; diff --git a/apps/web/src/components/DiffPanelShell.browser.tsx b/apps/web/src/components/DiffPanelShell.browser.tsx deleted file mode 100644 index d767fdba609..00000000000 --- a/apps/web/src/components/DiffPanelShell.browser.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import "../index.css"; - -import { describe, expect, it } from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { DiffPanelShell } from "./DiffPanelShell"; - -describe("DiffPanelShell", () => { - 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/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..61fae76f8ef 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, @@ -66,6 +64,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", () => { @@ -346,17 +358,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 +377,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 +531,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 +583,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 +599,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 +617,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -721,8 +755,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 +774,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 +786,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 +868,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 +880,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 +896,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 +918,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 +942,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 +965,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 +982,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..0ca86ae8f32 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,7 +18,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; }; @@ -148,7 +148,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; @@ -226,27 +226,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 +378,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 +556,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..b943fb5a69d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { ThreadStatusLabel, } 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,9 +40,9 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, - type DesktopUpdateState, ProjectId, type ScopedThreadRef, + type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, @@ -52,7 +53,12 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; +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 +67,28 @@ 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 { 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, + 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 +97,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 +174,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -181,19 +196,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 { 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 +212,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 +234,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 +251,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 +279,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; + keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< @@ -361,35 +385,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 +480,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 +511,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], ); @@ -981,7 +1047,7 @@ interface SidebarProjectItemProps { isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; - handleNewThread: ReturnType["handleNewThread"]; + handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; @@ -1027,14 +1093,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.defaultThreadEnvMode, ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); - const { updateSettings } = useUpdateSettings(); + const deleteProject = useAtomCommand(projectEnvironment.delete, { + reportFailure: false, + }); + const updateProject = useAtomCommand(projectEnvironment.update, { + reportFailure: false, + }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const updateSettings = useUpdateSettings(); const sidebarThreadPreviewCount = useSettings( (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); @@ -1103,15 +1178,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); }); }, []); - const sidebarThreads = useStore( - useShallow( - useMemo( - () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), - [project.memberProjectRefs], - ), - ), - ); + const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); const sidebarThreadByKey = useMemo( () => new Map( @@ -1128,8 +1195,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 +1283,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1313,15 +1380,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 +1400,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 +1425,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 +1440,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 +1488,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 +1510,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,7 +1522,20 @@ 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."; @@ -1464,7 +1547,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1477,8 +1560,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,9 +1570,9 @@ 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, @@ -1499,7 +1582,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1535,7 +1618,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 +1811,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 +1846,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 = @@ -1785,13 +1881,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec 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, + }), + ); + 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], ); @@ -1811,16 +1921,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 +1962,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 +2012,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 +2031,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [updateThreadMetadata], ); const closeProjectRenameDialog = useCallback(() => { @@ -1933,32 +2053,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 +2077,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -2010,7 +2120,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 +2172,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 +2191,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, memberProjectByScopedKey, - project.cwd, + project.workspaceRoot, startThreadRename, ], ); @@ -2119,7 +2240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} @@ -2187,7 +2308,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 +2348,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 +2395,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."} @@ -2632,7 +2753,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType["updateSettings"]; + updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -2640,7 +2761,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,8 +3014,8 @@ 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); @@ -2906,8 +3027,8 @@ export default function Sidebar() { const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); - const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useNewThreadHandler(); + const updateSettings = useUpdateSettings(); + const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeThreadRef = useParams({ @@ -2915,8 +3036,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 +3050,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 +3101,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 +3156,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 +3222,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 +3305,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 +3359,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 +3399,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 +3485,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 +3590,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.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ed2df1c79a0..8eac1fa412a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,15 +1,16 @@ -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 { 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"; @@ -154,19 +155,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 +216,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..7c25a8a1f75 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 { @@ -443,7 +447,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -509,7 +513,7 @@ export interface ChatComposerProps { onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, - ) => Promise; + ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; @@ -2419,11 +2423,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..3207876f706 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, }, }; @@ -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..b0d83be7b10 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)} )} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 3a9b421de01..7c138b294df 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -113,7 +113,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { () => providedKeybindings ?? [], [providedKeybindings], ); - const { updateSettings } = useUpdateSettings(); + const updateSettings = useUpdateSettings(); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); 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..ebc47966702 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; 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/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/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("re-reports ownership only after a later transport generation connects", () => { + const initial = observeAutomationOwnerConnectedGeneration(null, 1); + expect(initial).toEqual({ + nextGeneration: 1, + shouldReport: false, + }); + + 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..a1b24cd5553 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -1,6 +1,6 @@ "use client"; -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { PreviewAutomationNavigateInput, PreviewAutomationOpenInput, @@ -11,25 +11,47 @@ import type { } from "@t3tools/contracts"; import { useCallback, useEffect, useId, useRef } 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 { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentConnectionState } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; +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 !== null && previousGeneration !== connectedGeneration, + }; +} + const waitForDesktopOverlay = async ( threadRef: ScopedThreadRef, timeoutMs: number, ): Promise => { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const state = readThreadPreviewState(threadRef); const tabId = state.snapshot?.tabId; if (tabId && state.desktopOverlay && previewBridge) { const status = await previewBridge.automation.status(tabId); @@ -70,7 +92,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); @@ -113,7 +135,32 @@ export function PreviewAutomationOwner(props: { }) { const { threadRef, visible } = props; const automationClientId = useId(); + const automationRequests = useEnvironmentQuery( + previewEnvironment.automationRequests({ + environmentId: threadRef.environmentId, + input: { clientId: automationClientId }, + }), + ); + 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 ownerStateRef = useRef({ threadRef, visible }); + const connectedGenerationRef = useRef(null); const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise>( async () => undefined, ); @@ -128,11 +175,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 +185,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); @@ -213,11 +263,11 @@ export function PreviewAutomationOwner(props: { ); case "recordingStart": { if (!tabId) throw new Error("Preview tab is not initialized."); - await startBrowserRecording(tabId); + const startedAt = await startBrowserRecording(tabId); return { tabId, recording: true, - startedAt: useBrowserRecordingStore.getState().startedAt, + startedAt, }; } case "recordingStop": { @@ -228,84 +278,93 @@ export function PreviewAutomationOwner(props: { } } }, - [threadRef, visible], + [open, threadRef, visible], ); useEffect(() => { handlerRef.current = handleRequest; }, [handleRequest]); 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 request = automationRequests.data; + if (!request) return; + void handlerRef.current(request).then( + (result) => + respondToAutomation({ + environmentId: threadRef.environmentId, + input: { + requestId: request.requestId, + ok: true, + ...(result === undefined ? {} : { result }), + }, + }), + (error) => + respondToAutomation({ + environmentId: threadRef.environmentId, + input: { + requestId: request.requestId, + ok: false, + error: serializeError(error), + }, + }), ); - }, [automationClientId, threadRef.environmentId]); + }, [automationRequests.data, respondToAutomation, threadRef.environmentId]); useEffect(() => { - const api = ensureEnvironmentApi(threadRef.environmentId); - const report = () => { - const state = selectThreadPreviewState( - usePreviewStateStore.getState().byThreadKey, - threadRef, - ); - void api.preview.automation.reportOwner({ + const observation = observeAutomationOwnerConnectedGeneration( + connectedGenerationRef.current, + connectedGeneration, + ); + connectedGenerationRef.current = observation.nextGeneration; + if (!observation.shouldReport) return; + + const ownerState = ownerStateRef.current; + const state = readThreadPreviewState(ownerState.threadRef); + void reportAutomationOwner({ + environmentId: ownerState.threadRef.environmentId, + input: { clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, + environmentId: ownerState.threadRef.environmentId, + threadId: ownerState.threadRef.threadId, tabId: state.snapshot?.tabId ?? null, - visible, + visible: ownerState.visible, supportsAutomation: Boolean(previewBridge?.automation), focusedAt: new Date().toISOString(), + }, + }); + }, [automationClientId, connectedGeneration, reportAutomationOwner]); + + useEffect(() => { + const report = () => { + const state = readThreadPreviewState(threadRef); + void 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(), + }, }); }; report(); 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 }, + }); }; - }, [automationClientId, threadRef, visible]); + }, [automationClientId, clearAutomationOwner, reportAutomationOwner, threadRef, visible]); 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/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..81db47c4e9c 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,34 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; +beforeEach(resetPreviewStateForTests); + describe("openPreviewSession", () => { 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..1fd11bb587b 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -1,26 +1,40 @@ -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"]; } -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, + url: input.url, + }, }); - input.applyServerSnapshot(input.threadRef, snapshot); - input.rememberUrl( + if (result._tag === "Failure") { + return result; + } + const snapshot = result.value; + applyPreviewServerSnapshot(input.threadRef, snapshot); + rememberPreviewUrl( input.threadRef, snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, ); - return snapshot; + 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/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..e5444bdd22d 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -1,110 +1,111 @@ "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 { 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"; +const previewSessionSyncAtom = Atom.family((threadKey: string) => { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) { + throw new Error(`Invalid scoped preview thread key: ${threadKey}`); + } -/** - * 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; - } - - 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); - - 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..59f069c9e2b 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -2,7 +2,7 @@ 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, @@ -115,14 +115,13 @@ interface AddProviderInstanceDialogProps { export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); + const updateSettings = useUpdateSettings(); 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")} +

} > - {savedEnvironmentIds.map((environmentId) => ( + {savedEnvironments.map((environment) => ( ))} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 3a36e2a51e5..6df3367c642 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -7,6 +7,11 @@ import { InfoIcon, RefreshCwIcon, } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { ServerProcessDiagnosticsEntry, @@ -16,21 +21,23 @@ import type { import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { ensureLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTime } from "../../timestampFormat"; -import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; +import { useEnvironmentQuery } from "../../state/query"; import { - useProcessDiagnostics, - useProcessResourceHistory, -} from "../../lib/processDiagnosticsState"; -import { useTraceDiagnostics } from "../../lib/traceDiagnosticsState"; + primaryServerAvailableEditorsAtom, + primaryServerObservabilityAtom, + serverEnvironment, +} from "../../state/server"; +import { shellEnvironment } from "../../state/shell"; +import { usePrimaryEnvironment } from "../../state/environments"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; +import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -803,28 +810,51 @@ function DiagnosticsRefreshButton({ } export function DiagnosticsSettingsPanel() { - const observability = useServerObservability(); - const availableEditors = useServerAvailableEditors(); + const observability = useAtomValue(primaryServerObservabilityAtom); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { + reportFailure: false, + }); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); const selectedResourceWindow = RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; - const { data, error, isPending, refresh } = useTraceDiagnostics(); + const { data, error, isPending, refresh } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), + ); const { data: processData, error: processError, isPending: isProcessPending, refresh: refreshProcesses, - } = useProcessDiagnostics(); + } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.processDiagnostics({ environmentId, input: {} }), + ); const { data: resourceData, error: resourceError, isPending: isResourcePending, refresh: refreshResources, - } = useProcessResourceHistory({ - windowMs: selectedResourceWindow.windowMs, - bucketMs: selectedResourceWindow.bucketMs, - }); + } = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.processResourceHistory({ + environmentId, + input: { + windowMs: selectedResourceWindow.windowMs, + bucketMs: selectedResourceWindow.bucketMs, + }, + }), + ); const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); const [signalingPid, setSignalingPid] = useState(null); @@ -838,20 +868,30 @@ export function DiagnosticsSettingsPanel() { setOpenLogsDirectoryError("No available editors found."); return; } + if (environmentId === null) { + setOpenLogsDirectoryError("No environment is selected."); + return; + } setIsOpeningLogsDirectory(true); setOpenLogsDirectoryError(null); - void ensureLocalApi() - .shell.openInEditor(logsDirectoryPath, editor) - .catch((error: unknown) => { + void (async () => { + const result = await openInEditor({ + environmentId, + input: { + cwd: logsDirectoryPath, + editor, + }, + }); + setIsOpeningLogsDirectory(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); setOpenLogsDirectoryError( error instanceof Error ? error.message : "Unable to open logs folder.", ); - }) - .finally(() => { - setIsOpeningLogsDirectory(false); - }); - }, [availableEditors, observability?.logsDirectoryPath]); + } + })(); + }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); const isInitialLoading = isPending && data === null; const isProcessInitialLoading = isProcessPending && processData === null; @@ -863,45 +903,52 @@ export function DiagnosticsSettingsPanel() { ) { return; } + if (environmentId === null) { + return; + } setSignalingPid(pid); - void ensureLocalApi() - .server.signalProcess({ pid, signal }) - .then((result) => { - if (!result.signaled) { - const message = Option.getOrUndefined(result.message); - refreshProcesses(); - if (isStaleProcessSignalMessage(message)) { - toastManager.add({ - type: "info", - title: "Process already exited", - description: - "The process is not a child of the T3 Server. It might already have exited.", - }); - return; - } - + void (async () => { + const result = await signalServerProcess({ + environmentId, + input: { pid, signal }, + }); + setSignalingPid(null); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: `Could not send ${signal}`, - description: message ?? `Failed to send ${signal}.`, + description: error instanceof Error ? error.message : `Failed to send ${signal}.`, }); - return; } + return; + } + if (!result.value.signaled) { + const message = Option.getOrUndefined(result.value.message); refreshProcesses(); - }) - .catch((error: unknown) => { + if (isStaleProcessSignalMessage(message)) { + toastManager.add({ + type: "info", + title: "Process already exited", + description: + "The process is not a child of the T3 Server. It might already have exited.", + }); + return; + } + toastManager.add({ type: "error", title: `Could not send ${signal}`, - description: error instanceof Error ? error.message : `Failed to send ${signal}.`, + description: message ?? `Failed to send ${signal}.`, }); - }) - .finally(() => { - setSignalingPid(null); - }); + return; + } + refreshProcesses(); + })(); }, - [refreshProcesses], + [environmentId, refreshProcesses, signalServerProcess], ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 659823aec74..b7dbbd3575b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -27,13 +27,23 @@ import { type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../../env"; -import { openInPreferredEditor } from "../../editorPreferences"; +import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; -import { ensureLocalApi } from "../../localApi"; -import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState"; +import { + primaryServerAvailableEditorsAtom, + primaryServerKeybindingsAtom, + primaryServerKeybindingsConfigPathAtom, + serverEnvironment, +} from "../../state/server"; +import { usePrimaryEnvironment } from "../../state/environments"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; @@ -61,6 +71,7 @@ import { } from "./KeybindingsSettings.logic"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { const parts = value.split("+"); @@ -1069,8 +1080,20 @@ function NewKeybindingTableRow({ } export function KeybindingsSettingsPanel() { - const keybindings = useServerKeybindings(); - const keybindingsConfigPath = useServerKeybindingsConfigPath(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { + reportFailure: false, + }); + const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); + const openInPreferredEditor = useOpenInPreferredEditor( + primaryEnvironment?.environmentId ?? null, + availableEditors, + ); const [query, setQuery] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -1107,56 +1130,76 @@ export function KeybindingsSettingsPanel() { const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; - void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => { + void (async () => { + const result = await openInPreferredEditor(keybindingsConfigPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); toastManager.add({ title: "Unable to open keybindings file", description: error instanceof Error ? error.message : "The keybindings file was not opened.", type: "error", }); - }); - }, [keybindingsConfigPath]); + })(); + }, [keybindingsConfigPath, openInPreferredEditor]); - const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => { - setSavingCommand(input.command); - const payload: ServerUpsertKeybindingInput = { - command: input.command, - key: input.key.trim(), - ...(input.when?.trim() ? { when: input.when.trim() } : {}), - ...(input.replace ? { replace: input.replace } : {}), - }; - void ensureLocalApi() - .server.upsertKeybinding(payload) - .then(() => { - setIsAddingBinding(false); - }) - .catch((error: unknown) => { - toastManager.add({ - title: "Unable to save keybinding", - description: error instanceof Error ? error.message : "The keybinding was not saved.", - type: "error", + const saveKeybinding = useCallback( + (input: ServerUpsertKeybindingInput) => { + if (!primaryEnvironment) return; + setSavingCommand(input.command); + const payload: ServerUpsertKeybindingInput = { + command: input.command, + key: input.key.trim(), + ...(input.when?.trim() ? { when: input.when.trim() } : {}), + ...(input.replace ? { replace: input.replace } : {}), + }; + void (async () => { + const result = await upsertKeybinding({ + environmentId: primaryEnvironment.environmentId, + input: payload, }); - }) - .finally(() => { setSavingCommand(null); - }); - }, []); + if (result._tag === "Success") { + setIsAddingBinding(false); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + title: "Unable to save keybinding", + description: error instanceof Error ? error.message : "The keybinding was not saved.", + type: "error", + }); + } + })(); + }, + [primaryEnvironment, upsertKeybinding], + ); - const removeKeybinding = useCallback((row: KeybindingRow) => { - setSavingCommand(row.command); - void ensureLocalApi() - .server.removeKeybinding(rowKeybindingTarget(row)) - .catch((error: unknown) => { - toastManager.add({ - title: "Unable to remove keybinding", - description: error instanceof Error ? error.message : "The keybinding was not removed.", - type: "error", + const removeKeybinding = useCallback( + (row: KeybindingRow) => { + if (!primaryEnvironment) return; + setSavingCommand(row.command); + void (async () => { + const result = await removeKeybindingMutation({ + environmentId: primaryEnvironment.environmentId, + input: rowKeybindingTarget(row), }); - }) - .finally(() => { setSavingCommand(null); - }); - }, []); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + title: "Unable to remove keybinding", + description: error instanceof Error ? error.message : "The keybinding was not removed.", + type: "error", + }); + } + })(); + }, + [primaryEnvironment, removeKeybindingMutation], + ); const resetKeybinding = useCallback( (row: KeybindingRow) => { diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 823f8f968ad..ac2f7be81e8 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useEffect, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -161,10 +161,6 @@ function ProviderEnvironmentSection(props: { props.environment.map(makeEnvironmentDraftRow), ); - useEffect(() => { - setRows(props.environment.map(makeEnvironmentDraftRow)); - }, [props.environment]); - const publishRows = (nextRows: ReadonlyArray) => { const published: ProviderInstanceEnvironmentVariable[] = []; for (const row of nextRows) { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx deleted file mode 100644 index 339c817bd72..00000000000 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ /dev/null @@ -1,1541 +0,0 @@ -import "../../index.css"; - -import { - type AuthAccessStreamEvent, - type AuthAccessSnapshot, - type AuthEnvironmentScope, - AuthSessionId, - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - type DesktopBridge, - type DesktopUpdateChannel, - type DesktopUpdateState, - type LocalApi, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerProcessResourceHistoryResult, - type ServerProvider, - type SourceControlDiscoveryResult, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import { page } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; -import type { ReactNode } from "react"; -import { - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from "@tanstack/react-router"; - -import { __resetLocalApiForTests } from "../../localApi"; -import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; -import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; -import { useUiStateStore } from "../../uiStateStore"; -import { ConnectionsSettings } from "./ConnectionsSettings"; -import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; -import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; -import { SourceControlSettingsPanel } from "./SourceControlSettings"; - -function renderWithTestRouter(children: ReactNode) { - const rootRoute = createRootRoute({ - component: () => children, - }); - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/", - }); - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/"] }), - }); - - return render(); -} - -const authAccessHarness = vi.hoisted(() => { - type Snapshot = AuthAccessSnapshot; - let snapshot: Snapshot = { - pairingLinks: [], - clientSessions: [], - }; - let revision = 1; - const listeners = new Set<(event: AuthAccessStreamEvent) => void>(); - - const emitEvent = (event: AuthAccessStreamEvent) => { - for (const listener of listeners) { - listener(event); - } - }; - - return { - reset() { - snapshot = { - pairingLinks: [], - clientSessions: [], - }; - revision = 1; - listeners.clear(); - }, - setSnapshot(next: Snapshot) { - snapshot = next; - }, - emitSnapshot() { - emitEvent({ - version: 1 as const, - revision, - type: "snapshot" as const, - payload: snapshot, - }); - revision += 1; - }, - emitEvent, - emitPairingLinkUpserted(pairingLink: Snapshot["pairingLinks"][number]) { - emitEvent({ - version: 1, - revision, - type: "pairingLinkUpserted", - payload: pairingLink, - }); - revision += 1; - }, - emitPairingLinkRemoved(id: string) { - emitEvent({ - version: 1, - revision, - type: "pairingLinkRemoved", - payload: { id }, - }); - revision += 1; - }, - emitClientUpserted(clientSession: Snapshot["clientSessions"][number]) { - emitEvent({ - version: 1, - revision, - type: "clientUpserted", - payload: clientSession, - }); - revision += 1; - }, - emitClientRemoved(sessionId: string) { - emitEvent({ - version: 1, - revision, - type: "clientRemoved", - payload: { - sessionId: AuthSessionId.make(sessionId), - }, - }); - revision += 1; - }, - subscribe(listener: (event: AuthAccessStreamEvent) => void) { - listeners.add(listener); - listener({ - version: 1, - revision: 1, - type: "snapshot", - payload: snapshot, - }); - return () => { - listeners.delete(listener); - }; - }, - }; -}); - -const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); -const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null)); -const mockOpenClerkWaitlist = vi.hoisted(() => vi.fn()); - -vi.mock("@clerk/react", () => ({ - useAuth: () => ({ - getToken: mockGetClerkToken, - isSignedIn: false, - }), - useClerk: () => ({ - openWaitlist: mockOpenClerkWaitlist, - }), -})); - -vi.mock("../../environments/runtime", () => { - const primaryConnection = { - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Local environment", - source: "manual" as const, - environmentId: EnvironmentId.make("environment-local"), - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - }, - environmentId: EnvironmentId.make("environment-local"), - client: { - server: { - subscribeAuthAccess: (listener: Parameters[0]) => - authAccessHarness.subscribe(listener), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }; - - return { - getEnvironmentHttpBaseUrl: () => "http://localhost:3000", - getSavedEnvironmentRecord: () => null, - getSavedEnvironmentRuntimeState: () => null, - hasSavedEnvironmentRegistryHydrated: () => true, - listSavedEnvironmentRecords: () => [], - resetSavedEnvironmentRegistryStoreForTests: () => undefined, - resetSavedEnvironmentRuntimeStoreForTests: () => undefined, - resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => - new URL(path, "http://localhost:3000").toString(), - waitForSavedEnvironmentRegistryHydration: async () => undefined, - addManagedRelayEnvironment: vi.fn(), - addSavedEnvironment: vi.fn(), - connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, - disconnectSavedEnvironment: vi.fn(), - ensureEnvironmentConnectionBootstrapped: async () => undefined, - getPrimaryEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, - reconnectSavedEnvironment: vi.fn(), - removeSavedEnvironment: vi.fn(), - requireEnvironmentConnection: () => primaryConnection, - resetEnvironmentServiceForTests: () => undefined, - startEnvironmentConnectionService: () => undefined, - subscribeEnvironmentConnections: () => () => {}, - useSavedEnvironmentRegistryStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - useSavedEnvironmentRuntimeStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - }; -}); - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [], - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpTracesEnabled: true, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, - }; -} - -function createOutdatedProvider( - driver: string, - updateCommand = "npm install -g openai/codex@latest", -): ServerProvider { - return { - instanceId: ProviderInstanceId.make(driver), - driver: ProviderDriverKind.make(driver), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-05-04T10:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - versionAdvisory: { - status: "behind_latest", - currentVersion: "1.0.0", - latestVersion: "1.1.0", - message: "Update available.", - checkedAt: "2026-05-04T10:00:00.000Z", - updateCommand, - canUpdate: true, - }, - }; -} - -function makeUtc(value: string) { - return DateTime.makeUnsafe(value); -} - -function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistoryResult { - return { - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - windowMs: 15 * 60_000, - bucketMs: 60_000, - sampleIntervalMs: 5_000, - retainedSampleCount: 0, - totalCpuSecondsApprox: 0, - buckets: [], - topProcesses: [], - error: Option.none(), - }; -} - -function makePairingLink(input: { - readonly id: string; - readonly credential: string; - readonly scopes: ReadonlyArray; - readonly subject: string; - readonly label?: string; - readonly createdAt: string; - readonly expiresAt: string; -}): AuthAccessSnapshot["pairingLinks"][number] { - return { - ...input, - createdAt: makeUtc(input.createdAt), - expiresAt: makeUtc(input.expiresAt), - }; -} - -function makeClientSession(input: { - readonly sessionId: string; - readonly subject: string; - readonly scopes: ReadonlyArray; - readonly method: "browser-session-cookie"; - readonly client?: { - readonly label?: string; - readonly ipAddress?: string; - readonly userAgent?: string; - readonly deviceType?: "desktop" | "mobile" | "tablet" | "bot" | "unknown"; - readonly os?: string; - readonly browser?: string; - }; - readonly issuedAt: string; - readonly expiresAt: string; - readonly lastConnectedAt?: string | null; - readonly connected: boolean; - readonly current: boolean; -}): AuthAccessSnapshot["clientSessions"][number] { - return { - ...input, - client: { - deviceType: "unknown", - ...input.client, - }, - sessionId: AuthSessionId.make(input.sessionId), - issuedAt: makeUtc(input.issuedAt), - expiresAt: makeUtc(input.expiresAt), - lastConnectedAt: - input.lastConnectedAt === undefined || input.lastConnectedAt === null - ? null - : makeUtc(input.lastConnectedAt), - }; -} - -const createDesktopBridgeStub = (overrides?: { - readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; - readonly serverExposureState?: Awaited>; - readonly advertisedEndpoints?: Awaited>; - readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; - readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; -}): DesktopBridge => { - const idleUpdateState: DesktopUpdateState = { - enabled: false, - status: "idle", - channel: "latest", - currentVersion: "0.0.0-test", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }; - - return { - getAppBranding: vi.fn().mockReturnValue(null), - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), - setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined), - getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), - setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), - removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), - discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), - ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ - target, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: "ssh-pairing-token", - })), - disconnectSshEnvironment: vi.fn().mockResolvedValue(undefined), - fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ - environmentId: "environment-ssh", - label: "SSH environment", - platform: { - os: "linux", - arch: "x64", - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, - }), - bootstrapSshBearerSession: vi.fn().mockResolvedValue({ - access_token: "ssh-bearer-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "Bearer", - expires_in: 3_600, - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }), - fetchSshSessionState: vi.fn().mockResolvedValue({ - authenticated: true, - auth: { - policy: "remote-reachable", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - scopes: ["orchestration:read", "access:write"], - sessionMethod: "bearer-access-token", - expiresAt: "2026-05-01T12:00:00.000Z", - }), - issueSshWebSocketTicket: vi.fn().mockResolvedValue({ - ticket: "ssh-ws-ticket", - expiresAt: "2026-05-01T12:05:00.000Z", - }), - onSshPasswordPrompt: vi.fn(() => () => {}), - resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), - getServerExposureState: vi.fn().mockResolvedValue( - overrides?.serverExposureState ?? { - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - ), - setServerExposureMode: - overrides?.setServerExposureMode ?? - vi.fn().mockImplementation(async (mode) => ({ - mode, - endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, - advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - })), - setTailscaleServeEnabled: vi.fn().mockImplementation(async (input) => ({ - mode: overrides?.serverExposureState?.mode ?? "network-accessible", - endpointUrl: overrides?.serverExposureState?.endpointUrl ?? "http://192.168.1.44:3773", - advertisedHost: overrides?.serverExposureState?.advertisedHost ?? "192.168.1.44", - tailscaleServeEnabled: input.enabled, - tailscaleServePort: input.port ?? 443, - })), - getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), - pickFolder: vi.fn().mockResolvedValue(null), - confirm: vi.fn().mockResolvedValue(false), - setTheme: vi.fn().mockResolvedValue(undefined), - showContextMenu: vi.fn().mockResolvedValue(null), - openExternal: vi.fn().mockResolvedValue(true), - createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), - getCloudAuthToken: vi.fn().mockResolvedValue(null), - setCloudAuthToken: vi.fn().mockResolvedValue(true), - clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), - fetchCloudAuth: vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: {}, - body: "", - }), - onCloudAuthCallback: () => () => {}, - onMenuAction: () => () => {}, - getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), - setUpdateChannel: - overrides?.setUpdateChannel ?? - vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({ - ...idleUpdateState, - channel, - })), - checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), - downloadUpdate: vi - .fn() - .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), - installUpdate: vi - .fn() - .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), - onUpdateState: () => () => {}, - }; -}; - -describe("GeneralSettingsPanel observability", () => { - let mounted: - | (Awaited> & { - cleanup?: () => Promise; - unmount?: () => Promise; - }) - | null = null; - - beforeEach(async () => { - resetServerStateForTests(); - await __resetLocalApiForTests(); - localStorage.clear(); - useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); - authAccessHarness.reset(); - mockConnectDesktopSshEnvironment.mockReset(); - }); - - afterEach(async () => { - if (mounted) { - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - } - mounted = null; - vi.unstubAllGlobals(); - Reflect.deleteProperty(window, "desktopBridge"); - Reflect.deleteProperty(window, "nativeApi"); - document.body.innerHTML = ""; - resetServerStateForTests(); - await __resetLocalApiForTests(); - authAccessHarness.reset(); - }); - - it("hides owner pairing tools in browser-served loopback builds without remote exposure", async () => { - Reflect.deleteProperty(window, "desktopBridge"); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [ - makeClientSession({ - sessionId: "session-owner", - subject: "browser-owner", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "Chrome on Mac", - deviceType: "desktop", - os: "macOS", - browser: "Chrome", - ipAddress: "127.0.0.1", - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: true, - current: true, - }), - ], - }); - const fetchMock = vi.fn().mockImplementation(async (input) => { - const url = String(input); - if (url.endsWith("/api/auth/session")) { - return new Response( - JSON.stringify({ - authenticated: true, - auth: createBaseServerConfig().auth, - scopes: ["orchestration:read", "access:write"], - sessionMethod: "browser-session-cookie", - expiresAt: "2036-05-07T00:00:00.000Z", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - - throw new Error(`Unhandled fetch GET ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - mounted = await render( - - - , - ); - - await expect - .element(page.getByRole("heading", { name: "This environment", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); - await expect - .element( - page.getByText( - "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing.", - ), - ) - .toBeInTheDocument(); - await expect.element(page.getByText("Authorized clients")).not.toBeInTheDocument(); - await expect.element(page.getByText("Chrome on Mac")).not.toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Remote environments", exact: true })) - .toBeInTheDocument(); - }); - - it("hides advertised endpoint rows when desktop network access is disabled", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - advertisedEndpoints: [ - { - id: "loopback", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - }, - { - id: "tailscale-ip", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.105.39.17:3773/", - wsBaseUrl: "ws://100.105.39.17:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - }, - ], - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [], - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "This machine", exact: true })) - .not.toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Tailscale IP", exact: true })) - .not.toBeInTheDocument(); - }); - - it("collapses advertised endpoints behind the network access summary", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.86.39:3773", - advertisedHost: "192.168.86.39", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - advertisedEndpoints: [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - }, - { - id: "desktop-lan:http://192.168.86.39:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://192.168.86.39:3773/", - wsBaseUrl: "ws://192.168.86.39:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - }, - { - id: "tailscale-ip:http://100.105.39.17:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.105.39.17:3773/", - wsBaseUrl: "ws://100.105.39.17:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - }, - ], - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: [], - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument(); - await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Local network", exact: true })) - .not.toBeInTheDocument(); - - await page.getByRole("button", { name: "+2" }).click(); - - await expect - .element(page.getByRole("heading", { name: "Local network", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument(); - await page.getByRole("button", { name: "Set as default" }).first().click(); - await expect.element(page.getByText("http://127.0.0.1:3773/").first()).toBeInTheDocument(); - }); - - it("shows diagnostics inside About with a diagnostics link", async () => { - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await renderWithTestRouter( - - - , - ); - - await expect.element(page.getByText("About")).toBeInTheDocument(); - await expect - .element(page.getByRole("heading", { name: "Diagnostics", exact: true })) - .toBeInTheDocument(); - await expect.element(page.getByRole("link", { name: "View diagnostics" })).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces.", - ), - ) - .toBeInTheDocument(); - }); - - it("creates and shows a pairing link when network access is enabled", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - let pairingLinks: Array = []; - let clientSessions: Array = [ - makeClientSession({ - sessionId: "session-owner", - subject: "desktop-bootstrap", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "This Mac", - deviceType: "desktop", - os: "macOS", - browser: "Electron", - ipAddress: "127.0.0.1", - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: true, - current: true, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks, - clientSessions, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockImplementation(async (input, init) => { - const url = String(input); - const method = init?.method ?? "GET"; - if (url.endsWith("/api/auth/pairing-token") && method === "POST") { - pairingLinks = [ - makePairingLink({ - id: "pairing-link-1", - credential: "pairing-token", - scopes: ["orchestration:read"], - subject: "one-time-token", - label: "Julius iPhone", - createdAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-04-10T00:05:00.000Z", - }), - ]; - clientSessions = [ - ...clientSessions, - makeClientSession({ - sessionId: "session-client", - subject: "one-time-token", - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: "Julius iPhone", - deviceType: "mobile", - os: "iOS", - browser: "Safari", - ipAddress: "192.168.1.88", - }, - issuedAt: "2036-04-07T00:01:00.000Z", - expiresAt: "2036-05-07T00:01:00.000Z", - connected: false, - current: false, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks, - clientSessions, - }); - return new Response( - JSON.stringify({ - id: "pairing-link-1", - credential: "pairing-token", - label: "Julius iPhone", - expiresAt: "2036-04-10T00:05:00.000Z", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - } - - throw new Error(`Unhandled fetch ${method} ${url}`); - }), - ); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Authorized clients")).toBeInTheDocument(); - await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); - await expect.element(page.getByText("This Mac")).toBeInTheDocument(); - await page.getByRole("button", { name: "Create link", exact: true }).click(); - await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); - await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); - await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked(); - await page.getByRole("button", { name: "Read only", exact: true }).click(); - await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked(); - await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked(); - await page.getByRole("button", { name: "Create link", exact: true }).click(); - authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); - authAccessHarness.emitClientUpserted(clientSessions[1]!); - await expect - .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" })) - .toBeInTheDocument(); - await expect - .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88")) - .toBeInTheDocument(); - await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click(); - await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument(); - await expect - .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) - .toBeInTheDocument(); - await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); - }); - - it("keeps authorized clients within a five-row fading scroll area", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions: Array.from({ length: 7 }, (_, index) => - makeClientSession({ - sessionId: `session-client-${index}`, - subject: `client-${index}`, - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: `Client ${index + 1}`, - deviceType: "desktop", - os: "macOS", - browser: "Electron", - ipAddress: `192.168.1.${index + 10}`, - }, - issuedAt: "2036-04-07T00:00:00.000Z", - expiresAt: "2036-05-07T00:00:00.000Z", - connected: index === 0, - current: index === 0, - }), - ), - }); - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Client 7")).toBeInTheDocument(); - const scrollArea = document.querySelector( - '[data-testid="authorized-clients-scroll-area"]', - ); - const viewport = scrollArea?.querySelector('[data-slot="scroll-area-viewport"]'); - - expect(scrollArea).not.toBeNull(); - expect(viewport).not.toBeNull(); - expect(scrollArea?.clientHeight).toBe(360); - expect(viewport?.scrollHeight).toBeGreaterThan(viewport?.clientHeight ?? 0); - expect(viewport?.className).toContain("mask-b-from"); - }); - - it("revokes all other paired clients from settings", async () => { - window.desktopBridge = createDesktopBridgeStub({ - serverExposureState: { - mode: "network-accessible", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - }); - let clientSessions: Array = [ - makeClientSession({ - sessionId: "session-owner", - subject: "desktop-bootstrap", - scopes: ["orchestration:read", "access:write"], - method: "browser-session-cookie", - client: { - label: "This Mac", - deviceType: "desktop", - os: "macOS", - browser: "Electron", - }, - issuedAt: "2036-04-05T00:00:00.000Z", - expiresAt: "2036-05-05T00:00:00.000Z", - connected: true, - current: true, - }), - makeClientSession({ - sessionId: "session-client", - subject: "one-time-token", - scopes: ["orchestration:read"], - method: "browser-session-cookie", - client: { - label: "Julius iPhone", - deviceType: "mobile", - os: "iOS", - browser: "Safari", - ipAddress: "192.168.1.88", - }, - issuedAt: "2036-04-05T00:01:00.000Z", - expiresAt: "2036-05-05T00:01:00.000Z", - connected: false, - current: false, - }), - ]; - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions, - }); - - const fetchMock = vi.fn().mockImplementation(async (input, init) => { - const url = String(input); - const method = init?.method ?? "GET"; - if (url.endsWith("/api/auth/clients/revoke-others") && method === "POST") { - clientSessions = clientSessions.filter((session) => session.current); - authAccessHarness.setSnapshot({ - pairingLinks: [], - clientSessions, - }); - authAccessHarness.emitClientRemoved("session-client"); - return new Response(JSON.stringify({ revokedCount: 1 }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - - throw new Error(`Unhandled fetch ${method} ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); - await page.getByRole("button", { name: "Revoke others", exact: true }).click(); - await expect.element(page.getByText("This Mac")).toBeInTheDocument(); - await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument(); - expect(fetchMock).toHaveBeenCalled(); - }); - - it("shows a disabled network access toggle with guidance in desktop builds", async () => { - const desktopBridge = createDesktopBridgeStub(); - window.desktopBridge = desktopBridge; - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - const networkAccessToggle = page.getByLabelText("Enable network access"); - await expect.element(networkAccessToggle).not.toBeDisabled(); - await networkAccessToggle.click(); - await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); - await expect - .element(page.getByText("T3 Code will restart to expose this environment over the network.")) - .toBeInTheDocument(); - await page.getByRole("button", { name: "Restart and enable", exact: true }).click(); - await vi.waitFor(() => { - expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); - }); - await expect.element(page.getByText("http://192.168.1.44:3773")).toBeInTheDocument(); - }); - - it("adds desktop ssh environments from the add-environment dialog", async () => { - const discoverSshHosts = vi.fn().mockResolvedValue([ - { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - source: "ssh-config" as const, - }, - ]); - window.desktopBridge = createDesktopBridgeStub({ - discoverSshHosts, - }); - mockConnectDesktopSshEnvironment.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-devbox"), - label: "Build box", - wsBaseUrl: "ws://127.0.0.1:3774/", - httpBaseUrl: "http://127.0.0.1:3774/", - createdAt: "2036-04-07T00:00:00.000Z", - lastConnectedAt: "2036-04-07T00:00:00.000Z", - desktopSsh: { - alias: "devbox.example.com", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - }, - }); - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Add environment", exact: true }).click(); - const addEnvironmentDialog = page.getByRole("dialog", { name: "Add Environment" }); - await expect - .element(addEnvironmentDialog.getByRole("heading", { name: "Add Environment", exact: true })) - .toBeInTheDocument(); - await addEnvironmentDialog.getByRole("button", { name: /^SSH\b/ }).click(); - await vi.waitFor(() => { - expect(discoverSshHosts).toHaveBeenCalledTimes(1); - }); - await expect - .element(page.getByRole("heading", { name: "devbox", exact: true })) - .toBeInTheDocument(); - - await addEnvironmentDialog.getByLabelText("SSH host or alias").fill("devbox.example.com"); - await addEnvironmentDialog.getByLabelText("Username").fill("julius"); - await addEnvironmentDialog.getByLabelText("Port").fill("2222"); - await addEnvironmentDialog - .getByRole("button", { name: "Add environment", exact: true }) - .first() - .click(); - - await vi.waitFor(() => { - expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox.example.com", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - }, - { label: "" }, - ); - }); - }); - - it("opens the logs folder in the preferred editor", async () => { - const openInEditor = vi.fn().mockResolvedValue(undefined); - window.nativeApi = { - persistence: { - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - }, - shell: { - openInEditor, - }, - server: { - getProcessDiagnostics: vi.fn().mockResolvedValue({ - serverPid: 1234, - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - processCount: 0, - totalRssBytes: 0, - totalCpuPercent: 0, - processes: [], - error: Option.none(), - }), - getProcessResourceHistory: vi - .fn() - .mockResolvedValue(createEmptyProcessResourceHistoryResult()), - getTraceDiagnostics: vi.fn().mockResolvedValue({ - traceFilePath: "/repo/project/.t3/traces.jsonl", - scannedFilePaths: ["/repo/project/.t3/traces.jsonl"], - readAt: makeUtc("2036-04-07T00:00:00.000Z"), - recordCount: 0, - parseErrorCount: 0, - firstSpanAt: Option.none(), - lastSpanAt: Option.none(), - failureCount: 0, - interruptionCount: 0, - slowSpanThresholdMs: 5_000, - slowSpanCount: 0, - logLevelCounts: {}, - topSpansByCount: [], - slowestSpans: [], - commonFailures: [], - latestFailures: [], - latestWarningAndErrorLogs: [], - partialFailure: Option.none(), - error: Option.none(), - }), - }, - } as unknown as LocalApi; - - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - const openLogsButton = page.getByLabelText("Open logs folder"); - await openLogsButton.click(); - - expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); - }); - - it("shows an OpenCode server URL field in provider settings", async () => { - setServerConfigSnapshot(createBaseServerConfig()); - - mounted = await render( - - - , - ); - - await page.getByLabelText("Toggle OpenCode details").click(); - - // The unified provider-instance card renders field labels without a - // driver-name prefix (the driver name is already shown in the card - // header), so the labels read "Server URL" / "Server password" - // rather than the old "OpenCode server URL" / "OpenCode server password". - await expect.element(page.getByText("Server URL")).toBeInTheDocument(); - await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); - await expect.element(page.getByText("Server password")).toBeInTheDocument(); - await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); - }); - - it("runs one-click provider updates from the provider card", async () => { - const updateProvider = vi.fn().mockResolvedValue({ - providers: [createOutdatedProvider("codex")], - }); - window.nativeApi = { - persistence: { - getClientSettings: vi.fn().mockResolvedValue(null), - setClientSettings: vi.fn().mockResolvedValue(undefined), - }, - server: { - updateProvider, - }, - } as unknown as LocalApi; - - setServerConfigSnapshot({ - ...createBaseServerConfig(), - providers: [createOutdatedProvider("codex")], - }); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Update available — view details" }).click(); - await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument(); - await page.getByRole("button", { name: "Update now" }).click(); - - expect(updateProvider).toHaveBeenCalledWith({ - provider: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - }); - }); - - it("keeps long provider update commands inside the fixed-width popover", async () => { - const longUpdateCommand = - "npm install -g @anthropic-ai/claude-code@latest --registry=https://registry.npmjs.org --cache=/tmp/t3code-provider-update-cache"; - - setServerConfigSnapshot({ - ...createBaseServerConfig(), - providers: [createOutdatedProvider("codex", longUpdateCommand)], - }); - - mounted = await render( - - - , - ); - - await page.getByRole("button", { name: "Update available — view details" }).click(); - await expect.element(page.getByText(longUpdateCommand)).toBeInTheDocument(); - - await vi.waitFor(() => { - const popup = document.querySelector('[data-slot="popover-popup"]'); - const commandCode = Array.from(document.querySelectorAll("code")).find( - (element) => element.textContent === longUpdateCommand, - ); - const scrollViewport = commandCode?.closest( - '[data-slot="scroll-area-viewport"]', - ); - - expect(popup).toBeTruthy(); - expect(commandCode).toBeTruthy(); - expect(scrollViewport).toBeTruthy(); - - const popupRect = popup!.getBoundingClientRect(); - const viewportRect = scrollViewport!.getBoundingClientRect(); - - expect(popupRect.width).toBeGreaterThan(300); - expect(popupRect.width).toBeLessThanOrEqual(337); - expect(viewportRect.right).toBeLessThanOrEqual(popupRect.right + 0.5); - expect(scrollViewport!.scrollWidth).toBeGreaterThan(scrollViewport!.clientWidth); - }); - }); -}); - -describe("SourceControlSettingsPanel discovery states", () => { - let mounted: - | (Awaited> & { - cleanup?: () => Promise; - unmount?: () => Promise; - }) - | null = null; - - beforeEach(async () => { - resetAppAtomRegistryForTests(); - await __resetLocalApiForTests(); - document.body.innerHTML = ""; - }); - - afterEach(async () => { - if (mounted) { - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - } - mounted = null; - Reflect.deleteProperty(window, "nativeApi"); - document.body.innerHTML = ""; - await __resetLocalApiForTests(); - resetAppAtomRegistryForTests(); - }); - - function setSourceControlDiscoveryStub( - discoverSourceControl: () => Promise, - ) { - window.nativeApi = { - server: { - discoverSourceControl, - }, - } as LocalApi; - } - - it("shows skeleton sections while the first source control scan is pending", async () => { - setSourceControlDiscoveryStub(() => new Promise(() => {})); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Version Control")).toBeInTheDocument(); - await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); - await expect - .element(page.getByRole("button", { name: "Rescan server environment" })) - .toBeDisabled(); - await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); - }); - - it("uses the shared empty state when discovery completes without tools", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan.", - ), - ) - .toBeInTheDocument(); - await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); - }); - - it("keeps discovered rows instead of showing the empty state", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); - }); - - it("shows unauthenticated API providers as available but not enabled", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [], - sourceControlProviders: [ - { - kind: "bitbucket", - label: "Bitbucket", - status: "available", - version: Option.none(), - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - detail: Option.none(), - auth: { - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - ), - }, - }, - ], - })); - - mounted = await render( - - - , - ); - - const bitbucketSwitch = page.getByRole("switch", { name: "Bitbucket availability" }); - - await expect.element(page.getByText("Not authenticated")).toBeInTheDocument(); - await expect - .element( - page.getByText( - "Available. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", - ), - ) - .toBeInTheDocument(); - await expect.element(bitbucketSwitch).toBeDisabled(); - await expect.element(bitbucketSwitch).not.toBeChecked(); - }); - - it("shows Git fetch interval settings inside the Git details dropdown", async () => { - setSourceControlDiscoveryStub(async () => ({ - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - })); - - mounted = await render( - - - , - ); - - const toggle = page.getByRole("button", { name: "Toggle Git details" }); - await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); - - await toggle.click(); - - await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); - await expect - .element(page.getByLabelText("Automatic Git fetch interval in seconds")) - .toBeVisible(); - await expect - .element(page.getByText("Automatic Git fetches run every 30 seconds")) - .not.toBeInTheDocument(); - }); - - it("does not rescan on remount while the discovery atom is fresh", async () => { - let calls = 0; - setSourceControlDiscoveryStub(async () => { - calls += 1; - return { - versionControlSystems: [ - { - kind: "git", - label: "Git", - executable: "git", - implemented: true, - status: "available", - version: Option.some("git version 2.50.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [], - }; - }); - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - expect(calls).toBe(1); - - const teardown = mounted.cleanup ?? mounted.unmount; - await teardown?.call(mounted).catch(() => {}); - mounted = null; - document.body.innerHTML = ""; - - mounted = await render( - - - , - ); - - await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); - expect(calls).toBe(1); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..f478eac7d96 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,7 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, @@ -11,7 +11,12 @@ import { type ProviderInstanceId, type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Arr from "effect/Array"; @@ -33,10 +38,7 @@ import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hos import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; -import { - setDesktopUpdateStateQueryData, - useDesktopUpdateState, -} from "../../lib/desktopUpdateReactQuery"; +import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -46,8 +48,13 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { useShallow } from "zustand/react/shallow"; -import { selectProjectsAcrossEnvironments, useStore } from "../../store"; +import { + primaryServerObservabilityAtom, + primaryServerProvidersAtom, + serverEnvironment, +} from "../../state/server"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -78,7 +85,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useServerObservability, useServerProviders } from "../../rpc/serverState"; +import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -155,11 +162,9 @@ function AboutVersionTitle() { } function AboutVersionSection() { - const queryClient = useQueryClient(); - const updateStateQuery = useDesktopUpdateState(); + const updateState = useDesktopUpdateState(); const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); - const updateState = updateStateQuery.data ?? null; const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; @@ -178,9 +183,6 @@ function AboutVersionSection() { setIsChangingUpdateChannel(true); void bridge .setUpdateChannel(channel) - .then((state) => { - setDesktopUpdateStateQueryData(queryClient, state); - }) .catch((error: unknown) => { toastManager.add( stackedThreadToast({ @@ -194,7 +196,7 @@ function AboutVersionSection() { setIsChangingUpdateChannel(false); }); }, - [queryClient, selectedUpdateChannel], + [selectedUpdateChannel], ); const handleButtonClick = useCallback(() => { @@ -204,20 +206,15 @@ function AboutVersionSection() { const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; if (action === "download") { - void bridge - .downloadUpdate() - .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: error instanceof Error ? error.message : "Download failed.", - }), - ); - }); + void bridge.downloadUpdate().catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }), + ); + }); return; } @@ -228,20 +225,15 @@ function AboutVersionSection() { ), ); if (!confirmed) return; - void bridge - .installUpdate() - .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", - }), - ); - }); + void bridge.installUpdate().catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }), + ); + }); return; } @@ -249,7 +241,6 @@ function AboutVersionSection() { void bridge .checkForUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (!result.checked) { toastManager.add( stackedThreadToast({ @@ -270,7 +261,7 @@ function AboutVersionSection() { }), ); }); - }, [queryClient, updateState]); + }, [updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; @@ -382,7 +373,7 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); + const updateSettings = useUpdateSettings(); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -482,9 +473,9 @@ export function useSettingsRestore(onRestored?: () => void) { export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); - const observability = useServerObservability(); - const serverProviders = useServerProviders(); + const updateSettings = useUpdateSettings(); + const observability = useAtomValue(primaryServerObservabilityAtom); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -918,8 +909,15 @@ export function GeneralSettingsPanel() { export function ProviderSettingsPanel() { const settings = useSettings(); - const { updateSettings } = useUpdateSettings(); - const serverProviders = useServerProviders(); + const updateSettings = useUpdateSettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< @@ -958,49 +956,61 @@ export function ProviderSettingsPanel() { if (refreshingRef.current) return; refreshingRef.current = true; setIsRefreshingProviders(true); - void ensureLocalApi() - .server.refreshProviders() - .catch((error: unknown) => { - console.warn("Failed to refresh providers", error); - }) - .finally(() => { - refreshingRef.current = false; - setIsRefreshingProviders(false); + if (!primaryEnvironment) { + refreshingRef.current = false; + setIsRefreshingProviders(false); + return; + } + void (async () => { + const result = await refreshServerProviders({ + environmentId: primaryEnvironment.environmentId, + input: {}, }); - }, []); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", squashAtomCommandFailure(result)); + } + })(); + }, [primaryEnvironment, refreshServerProviders]); - const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + if (!primaryEnvironment) return; + let started = false; + setUpdatingProviderDrivers((previous) => { + if (previous.has(candidate.driver)) { + return previous; + } + started = true; + const next = new Set(previous); + next.add(candidate.driver); + return next; + }); + if (!started) { + return; } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - try { - await ensureLocalApi().server.updateProvider({ - provider: candidate.driver, - instanceId: candidate.instanceId, + const result = await updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, }); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } finally { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } setUpdatingProviderDrivers((previous) => { if (!previous.has(candidate.driver)) { return previous; @@ -1009,8 +1019,9 @@ export function ProviderSettingsPanel() { next.delete(candidate.driver); return next; }); - } - }, []); + }, + [primaryEnvironment, updateProvider], + ); interface InstanceRow { readonly instanceId: ProviderInstanceId; @@ -1330,16 +1341,15 @@ export function ProviderSettingsPanel() { })} - + {isAddInstanceDialogOpen ? ( + + ) : null} ); } export function ArchivedThreadsPanel() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], @@ -1415,10 +1425,11 @@ export function ArchivedThreadsPanel() { ); if (clicked === "unarchive") { - try { - await unarchiveThread(threadRef); + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { refreshArchivedThreads(); - } catch (error) { + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1431,8 +1442,19 @@ export function ArchivedThreadsPanel() { } if (clicked === "delete") { - await confirmAndDeleteThread(threadRef); - refreshArchivedThreads(); + const result = await confirmAndDeleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } } }, [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], @@ -1476,13 +1498,28 @@ export function ArchivedThreadsPanel() { key={thread.id} onContextMenu={(event) => { event.preventDefault(); - void handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); }} title={thread.title} description={ @@ -1498,10 +1535,17 @@ export function ArchivedThreadsPanel() { variant="outline" size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" - onClick={() => - void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)) - .then(() => refreshArchivedThreads()) - .catch((error) => { + onClick={() => { + void (async () => { + const result = await unarchiveThread( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", @@ -1510,8 +1554,9 @@ export function ArchivedThreadsPanel() { error instanceof Error ? error.message : "An error occurred.", }), ); - }) - } + } + })(); + }} > Unarchive diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 00656f9fd2d..db1b2393626 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -14,10 +14,9 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { - refreshSourceControlDiscovery, - useSourceControlDiscovery, -} from "../../lib/sourceControlDiscoveryState"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -293,7 +292,7 @@ function DiscoveryItemRow({ function GitFetchIntervalSettings() { const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); - const { updateSettings } = useUpdateSettings(); + const updateSettings = useUpdateSettings(); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -439,13 +438,21 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const discovery = useSourceControlDiscovery(); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const discovery = useEnvironmentQuery( + environmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId, + input: {}, + }), + ); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; const isInitialScanPending = discovery.isPending && discovery.data === null; const handleScan = () => { - void refreshSourceControlDiscovery(); + discovery.refresh(); }; const scanButton = ( diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index d5b1b9bddd4..b28b967eefa 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,9 +1,10 @@ import { useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import { useServerProviders } from "../../rpc/serverState"; +import { primaryServerProvidersAtom } from "../../state/server"; import { getProviderUpdateSidebarPillView, type ProviderUpdateSidebarPillView, @@ -39,7 +40,7 @@ function latestProviderCheckedAt( export function SidebarProviderUpdatePill() { const navigate = useNavigate(); - const providers = useServerProviders(); + const providers = useAtomValue(primaryServerProvidersAtom); const [dismissedKeys, setDismissedKeys] = useState>(() => new Set()); const [renderedView, setRenderedView] = useState(null); const [pendingView, setPendingView] = useState(null); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d7e5b74d42d..c3ac56d1092 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,11 +1,7 @@ import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { isElectron } from "../../env"; -import { - setDesktopUpdateStateQueryData, - useDesktopUpdateState, -} from "../../lib/desktopUpdateReactQuery"; +import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { getArm64IntelBuildWarningDescription, @@ -22,8 +18,7 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function SidebarUpdatePill() { - const queryClient = useQueryClient(); - const state = useDesktopUpdateState().data ?? null; + const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; @@ -44,7 +39,6 @@ export function SidebarUpdatePill() { void bridge .downloadUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (result.completed) { toastManager.add({ type: "success", @@ -81,7 +75,6 @@ export function SidebarUpdatePill() { void bridge .installUpdate() .then((result) => { - setDesktopUpdateStateQueryData(queryClient, result.state); if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; @@ -103,7 +96,7 @@ export function SidebarUpdatePill() { ); }); } - }, [action, disabled, queryClient, state]); + }, [action, disabled, state]); if (!visible && !showArm64Warning) return null; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 1da5dadf74d..1ab8d05ac60 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -3,7 +3,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import { defaultInstanceIdForDriver, @@ -59,6 +59,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, + clearComposerDraftsEnvironment, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -696,6 +697,40 @@ describe("composerDraftStore project draft thread mapping", () => { resetComposerDraftStore(); }); + it("clears composer data for one environment without touching another", () => { + const store = useComposerDraftStore.getState(); + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { + threadId: otherThreadId, + }); + store.setPrompt(localDraftId, "local draft"); + store.setPrompt(remoteDraftId, "remote draft"); + store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" })); + store.setPrompt(localThreadRef, "local thread draft"); + store.setPrompt(remoteThreadRef, "remote thread draft"); + + clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull(); + expect(next.getComposerDraft(localDraftId)).toBeNull(); + expect(next.getComposerDraft(remoteDraftId)?.prompt).toBe("remote thread draft"); + expect(next.getComposerDraft(localThreadRef)).toBeNull(); + expect(next.getComposerDraft(remoteThreadRef)?.prompt).toBe("remote thread draft"); + expect(revokeSpy).toHaveBeenCalledWith("blob:local-draft"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); @@ -965,6 +1000,18 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); + }); + it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aaaa94c1dd2..b92595227a6 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -24,7 +24,7 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; @@ -3347,6 +3347,60 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { + useComposerDraftStore.setState((state) => { + const removedThreadKeys = new Set(); + + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (draftThread.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const threadKey of Object.keys(state.draftsByThreadKey)) { + if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const [logicalProjectKey, threadKey] of Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + + const nextLogicalMappings = Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalProjectKey, threadKey]) => + parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId && + !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDraftThreads = Object.fromEntries( + Object.entries(state.draftThreadsByThreadKey).filter( + ([threadKey, draftThread]) => + draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDrafts = Object.fromEntries( + Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => { + if (!removedThreadKeys.has(threadKey)) { + return true; + } + revokeDraftThreadPreviewUrls(draft); + return false; + }), + ) as Record; + + return { + draftsByThreadKey: nextDrafts, + draftThreadsByThreadKey: nextDraftThreads, + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + }; + }); + composerDebouncedStorage.flush(); +} + export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { return useComposerDraftStore((state) => { return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; @@ -3459,12 +3513,16 @@ export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable + JSON.stringify(input), + }, + execute: (input: { + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; + }) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerPairing(input))), +}); + +export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime, { + label: "web:connection:connect-ssh", + scheduler: onboardingScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target), + }, + execute: (input: { readonly target: DesktopSshEnvironmentTarget; readonly label?: string }) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerSsh(input))), +}); diff --git a/apps/web/src/connection/platform.test.ts b/apps/web/src/connection/platform.test.ts new file mode 100644 index 00000000000..2b428e26698 --- /dev/null +++ b/apps/web/src/connection/platform.test.ts @@ -0,0 +1,88 @@ +import { + AuthStandardClientScopes, + EnvironmentId, + type DesktopBridge, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { provisionDesktopSshEnvironment } from "./platform.ts"; + +const TARGET: DesktopSshEnvironmentTarget = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, +}; + +function makeBridge( + calls: string[], + options?: { readonly failDescriptor?: boolean }, +): DesktopBridge { + return { + ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { + calls.push("ensure"); + return { + target, + httpBaseUrl: "http://127.0.0.1:3201/", + wsBaseUrl: "ws://127.0.0.1:3201/", + pairingToken: "pairing-token", + }; + }, + fetchSshEnvironmentDescriptor: async () => { + calls.push("descriptor"); + if (options?.failDescriptor === true) { + throw new Error("descriptor unavailable"); + } + return { + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }; + }, + bootstrapSshBearerSession: async () => { + calls.push("token"); + return { + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3_600, + scope: AuthStandardClientScopes.join(" "), + }; + }, + } as unknown as DesktopBridge; +} + +describe("desktop SSH pairing", () => { + it.effect("fetches the descriptor before consuming the one-time credential", () => + Effect.gen(function* () { + const calls: string[] = []; + + const provisioned = yield* provisionDesktopSshEnvironment(makeBridge(calls), TARGET); + + expect(provisioned.environmentId).toBe(EnvironmentId.make("environment-ssh")); + expect(calls).toEqual(["ensure", "descriptor", "token"]); + }), + ); + + it.effect("does not consume the credential when descriptor discovery fails", () => + Effect.gen(function* () { + const calls: string[] = []; + + yield* provisionDesktopSshEnvironment( + makeBridge(calls, { failDescriptor: true }), + TARGET, + ).pipe(Effect.flip); + + expect(calls).toEqual(["ensure", "descriptor"]); + }), + ); +}); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts new file mode 100644 index 00000000000..5fe503ec383 --- /dev/null +++ b/apps/web/src/connection/platform.ts @@ -0,0 +1,352 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + ConnectionWakeups, + Connectivity, + mapRemoteEnvironmentError, + PrimaryConnectionRegistration, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { + AuthStandardClientScopes, + type DesktopBridge, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; + +import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; +import { clearComposerDraftsEnvironment } from "../composerDraftStore"; +import { isHostedStaticApp } from "../hostedPairing"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; +import { connectionStorageLayer } from "./storage"; + +let nextObservedRpcRequestId = 0; + +function currentNetworkStatus(): "unknown" | "offline" | "online" { + if (typeof navigator === "undefined") { + return "unknown"; + } + return navigator.onLine ? "online" : "offline"; +} + +const connectivityLayer = Layer.succeed( + Connectivity, + Connectivity.of({ + status: Effect.sync(currentNetworkStatus), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const online = () => Queue.offerUnsafe(queue, "online"); + const offline = () => Queue.offerUnsafe(queue, "offline"); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + return { online, offline }; + }), + ({ online, offline }) => + Effect.sync(() => { + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }), + ).pipe(Effect.asVoid), + ), + }), +); + +const wakeupsLayer = Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const listener = () => { + if (document.visibilityState === "visible") { + Queue.offerUnsafe(queue, "application-active"); + } + }; + document.addEventListener("visibilitychange", listener); + return listener; + }), + (listener) => + Effect.sync(() => { + document.removeEventListener("visibilitychange", listener); + }), + ).pipe(Effect.asVoid), + ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + ), + }), +); + +function clientMetadata() { + const desktop = window.desktopBridge !== undefined; + const platform = navigator.platform.trim(); + return { + label: desktop ? "T3 Code Desktop" : "T3 Code Web", + deviceType: "desktop" as const, + ...(platform === "" ? {} : { os: platform }), + }; +} + +function sshPreparationError(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + if (message.toLowerCase().includes("cancel")) { + return new ConnectionBlockedError({ + reason: "authentication", + message, + }); + } + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not prepare the SSH environment: ${message}`, + }); +} + +export const provisionDesktopSshEnvironment = Effect.fn( + "web.connectionPlatform.ssh.provisionDesktop", +)(function* (bridge: DesktopBridge, target: DesktopSshEnvironmentTarget) { + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + const pairingToken = bootstrap.pairingToken; + if (pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The SSH environment did not issue a pairing credential.", + }); + } + const descriptor = yield* Effect.tryPromise({ + try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl), + catch: sshPreparationError, + }); + const access = yield* Effect.tryPromise({ + try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken), + catch: sshPreparationError, + }); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + bootstrap, + bearerToken: access.access_token, + }; +}); + +const capabilitiesLayer = Layer.effectContext( + Effect.sync(() => { + const presentation = ClientPresentation.of({ + metadata: clientMetadata(), + scopes: AuthStandardClientScopes, + }); + const cloudSession = CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + message: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }); + const identity = RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.none()), + }); + const ssh = SshEnvironmentGateway.of({ + provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }); + } + return yield* provisionDesktopSshEnvironment(bridge, target); + }), + prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }); + } + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(input.target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + if (bootstrap.pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The SSH environment did not issue a pairing credential.", + }); + } + const access = yield* Effect.tryPromise({ + try: () => + bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), + catch: sshPreparationError, + }); + return { + bootstrap, + bearerToken: access.access_token, + }; + }), + disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return; + } + yield* Effect.tryPromise({ + try: () => bridge.disconnectSshEnvironment(target), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not disconnect the SSH environment: ${String(cause)}`, + }), + }); + }), + }); + + return Context.make(CloudSession, cloudSession).pipe( + Context.add(RelayDeviceIdentity, identity), + Context.add(ClientPresentation, presentation), + Context.add(SshEnvironmentGateway, ssh), + ); + }), +); + +const loadPrimaryConnectionRegistration = Effect.fn( + "web.connectionPlatform.loadPrimaryConnectionRegistration", +)(function* () { + const resolved = readPrimaryEnvironmentTarget(); + if (resolved === null) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Unable to resolve the primary environment endpoint.", + }); + } + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), + Effect.mapError(mapRemoteEnvironmentError), + ); + return new PrimaryConnectionRegistration({ + target: new PrimaryConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: resolved.target.httpBaseUrl, + wsBaseUrl: resolved.target.wsBaseUrl, + }), + }); +}); + +const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( + Schedule.either(Schedule.spaced("16 seconds")), +); + +const platformConnectionSourceLayer = Layer.effect( + PlatformConnectionSource, + Effect.gen(function* () { + if (isHostedStaticApp()) { + return PlatformConnectionSource.of({ + registrations: Stream.empty, + }); + } + const httpClient = yield* HttpClient.HttpClient; + return PlatformConnectionSource.of({ + registrations: Stream.fromEffect( + loadPrimaryConnectionRegistration().pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ).pipe( + Stream.tapError((error) => + Effect.logWarning("Could not discover the primary environment.", { + error, + }), + ), + Stream.retry(primaryRegistrationRetrySchedule), + Stream.catchCause(() => Stream.empty), + ), + }); + }), +); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.sync(() => { + clearComposerDraftsEnvironment(environmentId); + }), + }), +); + +const rpcRequestObserverLayer = Layer.succeed( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + nextObservedRpcRequestId += 1; + const requestId = `${environmentId}:${nextObservedRpcRequestId}`; + trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + return Effect.sync(() => { + acknowledgeRpcRequest(requestId); + }); + }), + }), +); + +export const connectionPlatformLayer = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, + rpcRequestObserverLayer, +); diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts new file mode 100644 index 00000000000..3b1eade0818 --- /dev/null +++ b/apps/web/src/connection/runtime.ts @@ -0,0 +1,16 @@ +import { connectionLayer as clientConnectionLayer } 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), +); + +export const connectionLayer = clientConnectionLayer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime = Atom.runtime(connectionLayer); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts new file mode 100644 index 00000000000..0f0656dee98 --- /dev/null +++ b/apps/web/src/connection/storage.test.ts @@ -0,0 +1,77 @@ +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { afterEach, vi } from "vite-plus/test"; + +import { makeCatalogBackend, makeCatalogStore } from "./storage"; + +const emptyCatalog = { + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], +} as const; +const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("makeCatalogStore", () => { + it.effect("quarantines malformed catalogs and starts from an empty document", () => + Effect.gen(function* () { + const writes: string[] = []; + const quarantined: string[] = []; + const store = yield* makeCatalogStore({ + read: Effect.succeed("{not-json"), + write: (raw) => Effect.sync(() => writes.push(raw)), + quarantine: (raw) => Effect.sync(() => quarantined.push(raw)), + }); + + expect(yield* store.read).toEqual(emptyCatalog); + expect(quarantined).toEqual(["{not-json"]); + expect(writes).toHaveLength(1); + expect(decodeCatalog(writes[0]!)).toEqual(emptyCatalog); + }), + ); + + it.effect("does not hide catalog read failures", () => + Effect.gen(function* () { + const failure = new ConnectionTransientError({ + reason: "remote-unavailable", + message: "permission denied", + }); + const store = yield* makeCatalogStore({ + read: Effect.fail(failure), + write: () => Effect.void, + }); + + expect(yield* Effect.flip(store.read)).toBe(failure); + }), + ); +}); + +describe("makeCatalogBackend", () => { + it.effect("fails writes when desktop secure storage declines the catalog", () => + Effect.gen(function* () { + const setConnectionCatalog = vi.fn().mockResolvedValue(false); + vi.stubGlobal("window", { + desktopBridge: { + getConnectionCatalog: vi.fn().mockResolvedValue(null), + setConnectionCatalog, + }, + }); + const backend = makeCatalogBackend({} as IDBDatabase); + + const error = yield* backend.write("{}").pipe(Effect.flip); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error.message).toContain("Desktop secure storage is unavailable"); + expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); + }), + ); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts new file mode 100644 index 00000000000..19a4a8454ed --- /dev/null +++ b/apps/web/src/connection/storage.ts @@ -0,0 +1,536 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeCatalogValue, + removeConnectionFromCatalog, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionCredentialStore, + ConnectionProfileStore, + ConnectionTransientError, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThread, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +const DATABASE_NAME = "t3code:connection-runtime"; +const DATABASE_VERSION = 2; +const CATALOG_STORE_NAME = "catalog"; +const SHELL_STORE_NAME = "shell"; +const THREAD_STORE_NAME = "thread"; +const CATALOG_KEY = "document"; +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); +const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); +const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); +const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); +const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function persistenceError( + operation: + | "list-targets" + | "register-connection" + | "remove-connection" + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { + return yield* Effect.callback((resume) => { + if (typeof indexedDB === "undefined") { + resume( + Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")), + ); + return; + } + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { + request.result.createObjectStore(CATALOG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { + request.result.createObjectStore(SHELL_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_STORE_NAME); + } + }); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }); +}); + +function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); +} + +function writeDatabaseValue( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, + value: unknown, +) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).put(value, key); + }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); +} + +function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).delete(key); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); +} + +function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const request = transaction.objectStore(storeName).openCursor(range); + request.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), + ); + }); + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) { + return; + } + cursor.delete(); + cursor.continue(); + }); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); +} + +function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { + return `${environmentId}:${threadId}`; +} + +const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); +}); + +const encodeCatalog = Effect.fn("web.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); +}); + +export interface CatalogBackend { + readonly read: Effect.Effect; + readonly write: (raw: string) => Effect.Effect; + readonly quarantine?: (raw: string) => Effect.Effect; +} + +export function makeCatalogBackend(database: IDBDatabase): CatalogBackend { + const bridge = window.desktopBridge; + if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { + return { + read: Effect.tryPromise({ + try: () => bridge.getConnectionCatalog!(), + catch: (cause) => catalogError("load", cause), + }), + write: (raw) => + Effect.tryPromise({ + try: () => bridge.setConnectionCatalog!(raw), + catch: (cause) => catalogError("save", cause), + }).pipe( + Effect.flatMap((stored) => + stored + ? Effect.void + : Effect.fail( + catalogError( + "save", + "Desktop secure storage is unavailable in this system context.", + ), + ), + ), + ), + }; + } + + return { + read: readDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY).pipe( + Effect.map((value) => (typeof value === "string" ? value : null)), + ), + write: (raw) => writeDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY, raw), + quarantine: (raw) => + writeDatabaseValue(database, CATALOG_STORE_NAME, `${CATALOG_KEY}:corrupt:${Date.now()}`, raw), + }; +} + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( + backend: CatalogBackend, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadUnlocked = Effect.fn("web.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* backend.read; + let catalog = EMPTY_CONNECTION_CATALOG_DOCUMENT; + if (raw !== null && raw.trim() !== "") { + catalog = yield* decodeCatalog(raw).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("Discarding a corrupt web connection catalog.", { + error: error.message, + }); + if (backend.quarantine !== undefined) { + yield* backend.quarantine(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not quarantine the corrupt web connection catalog.", { + error: cause.message, + }), + ), + ); + } + const encoded = yield* encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT); + yield* backend.write(encoded).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not persist the recovered web connection catalog.", { + error: cause.message, + }), + ), + ); + return EMPTY_CONNECTION_CATALOG_DOCUMENT; + }), + ), + ); + } + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("web.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + yield* backend.write(yield* encodeCatalog(next)); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const database = yield* Effect.acquireRelease(openDatabase(), (database) => + Effect.sync(() => database.close()), + ); + const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((cause) => persistenceError("list-targets", cause)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), + }); + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((profile) => profile.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = ConnectionCredentialStore.of({ + 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 = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredShellSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-shell", cause)), + Effect.map((stored) => + stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-shell", cause), + ), + ), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const encoded = yield* encodeStoredShellSnapshot({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-shell", cause), + ), + ), + loadThread: (environmentId, threadId) => + readDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredThreadSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-thread", cause)), + Effect.map((stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-thread", cause), + ), + ), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const encoded = yield* encodeStoredThreadSnapshot({ + schemaVersion: 1, + environmentId, + threadId: thread.id, + thread, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* writeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, thread.id), + encoded, + ); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-thread", cause), + ), + ), + removeThread: (environmentId, threadId) => + removeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + clear: (environmentId) => + Effect.all( + [ + removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), + removeDatabaseValuesInRange( + database, + THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ConnectionProfileStore, profileStore), + Context.add(ConnectionCredentialStore, credentialStore), + Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 38032c07a88..9c358ab1d29 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 38c59115a55..32bdf42a807 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,9 +1,25 @@ -import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; +import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts"; +import { + mapAtomCommandResult, + type AtomCommandFailure, + type AtomCommandResult, +} 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 { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import { shellEnvironment } from "./state/shell"; +import { useAtomCommand } from "./state/use-atom-command"; const LAST_EDITOR_KEY = "t3code:last-editor"; +export class PreferredEditorUnavailableError extends Data.TaggedError( + "PreferredEditorUnavailableError", +)<{ + readonly message: string; +}> {} + export function usePreferredEditor(availableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); @@ -26,10 +42,49 @@ export function resolveAndPersistPreferredEditor( return editor ?? null; } -export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { - const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); - if (!editor) throw new Error("No available editors found."); - await api.shell.openInEditor(targetPath, editor); - return editor; +export function useOpenInPreferredEditor( + environmentId: EnvironmentId | null, + availableEditors: readonly EditorId[], +) { + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + type OpenInEditorError = AtomCommandFailure>>; + + return useCallback( + async ( + targetPath: string, + ): Promise< + AtomCommandResult + > => { + if (environmentId === null) { + return AsyncResult.failure( + Cause.fail( + new PreferredEditorUnavailableError({ + message: "No environment is selected.", + }), + ), + ); + } + const editor = resolveAndPersistPreferredEditor(availableEditors); + if (!editor) { + return AsyncResult.failure( + Cause.fail( + new PreferredEditorUnavailableError({ + message: "No available editors found.", + }), + ), + ); + } + const result = await openInEditor({ + environmentId, + input: { + cwd: targetPath, + editor, + }, + }); + return mapAtomCommandResult(result, () => editor); + }, + [availableEditors, environmentId, openInEditor], + ); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts deleted file mode 100644 index ae373ac94f9..00000000000 --- a/apps/web/src/environmentApi.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { readEnvironmentConnection } from "./environments/runtime"; - -const environmentApiOverridesForTests = new Map(); - -export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { - return { - terminal: { - open: (input) => rpcClient.terminal.open(input as never), - attach: (input, callback, options) => - rpcClient.terminal.attach(input as never, callback, options), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), - }, - projects: { - listEntries: rpcClient.projects.listEntries, - readFile: rpcClient.projects.readFile, - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, - }, - filesystem: { - browse: rpcClient.filesystem.browse, - }, - assets: { - createUrl: rpcClient.assets.createUrl, - }, - sourceControl: { - lookupRepository: rpcClient.sourceControl.lookupRepository, - cloneRepository: rpcClient.sourceControl.cloneRepository, - publishRepository: rpcClient.sourceControl.publishRepository, - }, - vcs: { - pull: rpcClient.vcs.pull, - refreshStatus: rpcClient.vcs.refreshStatus, - onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), - listRefs: rpcClient.vcs.listRefs, - createWorktree: rpcClient.vcs.createWorktree, - removeWorktree: rpcClient.vcs.removeWorktree, - createRef: rpcClient.vcs.createRef, - switchRef: rpcClient.vcs.switchRef, - init: rpcClient.vcs.init, - }, - git: { - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, - }, - review: { - getDiffPreview: rpcClient.review.getDiffPreview, - }, - orchestration: { - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, - subscribeShell: (callback, options) => - rpcClient.orchestration.subscribeShell(callback, options), - subscribeThread: (input, callback, options) => - rpcClient.orchestration.subscribeThread(input, callback, options), - }, - preview: { - open: (input) => rpcClient.preview.open(input as never), - navigate: (input) => rpcClient.preview.navigate(input as never), - refresh: (input) => rpcClient.preview.refresh(input as never), - close: (input) => rpcClient.preview.close(input as never), - list: (input) => rpcClient.preview.list(input as never), - reportStatus: (input) => rpcClient.preview.reportStatus(input as never), - automation: { - connect: (input, callback, options) => - rpcClient.preview.automation.connect(input as never, callback, options), - respond: (response) => rpcClient.preview.automation.respond(response as never), - reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), - clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), - }, - onEvent: (callback, options) => rpcClient.preview.onEvent(callback, options), - subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), - }, - }; -} - -export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { - if (typeof window === "undefined") { - return undefined; - } - - if (!environmentId) { - return undefined; - } - - const overriddenApi = environmentApiOverridesForTests.get(environmentId); - if (overriddenApi) { - return overriddenApi; - } - - const connection = readEnvironmentConnection(environmentId); - return connection ? createEnvironmentApi(connection.client) : undefined; -} - -export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { - const api = readEnvironmentApi(environmentId); - if (!api) { - throw new Error(`Environment API not found for environment ${environmentId}`); - } - return api; -} - -export function __setEnvironmentApiOverrideForTests( - environmentId: EnvironmentId, - api: EnvironmentApi, -): void { - environmentApiOverridesForTests.set(environmentId, api); -} - -export function __resetEnvironmentApiOverridesForTests(): void { - environmentApiOverridesForTests.clear(); -} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index ae879c671f5..c66bf4977b2 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -1,54 +1,40 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - selectSidebarThreadsForProjectRef, - selectSidebarThreadsForProjectRefs, - type AppState, - type EnvironmentState, -} from "./store"; import { deriveLogicalProjectKey, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, resolveProjectGroupingMode, } from "./logicalProject"; -import type { Project, SidebarThreadSummary } from "./types"; -import { DEFAULT_INTERACTION_MODE } from "./types"; - -// ── Fixture Identifiers ────────────────────────────────────────────── - -const primaryEnvId = EnvironmentId.make("env-primary"); -const remoteEnvId = EnvironmentId.make("env-remote"); - -const sharedProjectPrimaryId = ProjectId.make("shared-proj-primary"); -const sharedProjectRemoteId = ProjectId.make("shared-proj-remote"); -const localOnlyProjectId = ProjectId.make("local-only-proj"); -const remoteOnlyProjectId = ProjectId.make("remote-only-proj"); - -const threadP1 = ThreadId.make("thread-shared-primary-1"); -const threadP2 = ThreadId.make("thread-shared-primary-2"); -const threadR1 = ThreadId.make("thread-shared-remote-1"); -const threadL1 = ThreadId.make("thread-local-only-1"); -const threadRO1 = ThreadId.make("thread-remote-only-1"); - -const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; -const DEFAULT_GROUPING_SETTINGS = { +import type { Project } from "./types"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const repositoryIdentity = { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, +}; +const defaultGroupingSettings = { sidebarProjectGroupingMode: "repository" as const, sidebarProjectGroupingOverrides: {}, }; -// ── Factory Helpers ────────────────────────────────────────────────── - -function makeProject( - overrides: Partial & Pick, -): Project { +function makeProject(overrides: Partial = {}): Project { return { - cwd: `/tmp/${overrides.name}`, - defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + id: ProjectId.make("project-1"), + environmentId: primaryEnvironmentId, + title: "shared-repo", + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], @@ -56,559 +42,81 @@ function makeProject( }; } -function makeSidebarThreadSummary( - overrides: Partial & - Pick, -): SidebarThreadSummary { - return { - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-01-01T00:00:00.000Z", - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - ...overrides, - }; -} - -function makeEmptyEnvironmentState(): EnvironmentState { - return { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; -} - -// ── Fixture: Two environments, shared + local-only + remote-only projects ── - -function makeFixtureState(): AppState { - // Shared project: same repo in both envs - const sharedProjectPrimary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const sharedProjectRemote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - // Local-only project - const localOnlyProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - // Remote-only project - const remoteOnlyProject = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - - // Threads - const summaryP1 = makeSidebarThreadSummary({ - id: threadP1, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 1", - }); - const summaryP2 = makeSidebarThreadSummary({ - id: threadP2, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 2", - }); - const summaryR1 = makeSidebarThreadSummary({ - id: threadR1, - environmentId: remoteEnvId, - projectId: sharedProjectRemoteId, - title: "Shared remote thread 1", - }); - const summaryL1 = makeSidebarThreadSummary({ - id: threadL1, - environmentId: primaryEnvId, - projectId: localOnlyProjectId, - title: "Local only thread 1", - }); - const summaryRO1 = makeSidebarThreadSummary({ - id: threadRO1, - environmentId: remoteEnvId, - projectId: remoteOnlyProjectId, - title: "Remote only thread 1", - }); - - const primaryEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectPrimaryId, localOnlyProjectId], - projectById: { - [sharedProjectPrimaryId]: sharedProjectPrimary, - [localOnlyProjectId]: localOnlyProject, - }, - threadIds: [threadP1, threadP2, threadL1], - threadIdsByProjectId: { - [sharedProjectPrimaryId]: [threadP1, threadP2], - [localOnlyProjectId]: [threadL1], - }, - sidebarThreadSummaryById: { - [threadP1]: summaryP1, - [threadP2]: summaryP2, - [threadL1]: summaryL1, - }, - }; - - const remoteEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectRemoteId, remoteOnlyProjectId], - projectById: { - [sharedProjectRemoteId]: sharedProjectRemote, - [remoteOnlyProjectId]: remoteOnlyProject, - }, - threadIds: [threadR1, threadRO1], - threadIdsByProjectId: { - [sharedProjectRemoteId]: [threadR1], - [remoteOnlyProjectId]: [threadRO1], - }, - sidebarThreadSummaryById: { - [threadR1]: summaryR1, - [threadRO1]: summaryRO1, - }, - }; - - return { - activeEnvironmentId: primaryEnvId, - environmentStateById: { - [primaryEnvId]: primaryEnvState, - [remoteEnvId]: remoteEnvState, - }, - }; -} - -// ── Tests ──────────────────────────────────────────────────────────── - describe("environment grouping", () => { - describe("deriveLogicalProjectKey", () => { - it("uses repositoryIdentity.canonicalKey when present", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(project)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("falls back to scoped project key when no repositoryIdentity", () => { - const project = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); - }); - - it("groups projects from different environments that share the same canonical key", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); - }); - - it("groups repo root and nested projects from the same repository by default", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); - expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("uses repository path grouping when requested", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(rootProject, { - groupingMode: "repository_path", - }), - ).toBe(SHARED_REPO_CANONICAL_KEY); - expect( - deriveLogicalProjectKey(nestedProject, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - }); - - it("groups matching nested project paths across environments when repo roots differ", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "web", - cwd: "/srv/checkout/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/srv/checkout", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe( - deriveLogicalProjectKey(remote, { - groupingMode: "repository_path", - }), - ); + it("groups matching repository identities across environments", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, }); - it("does NOT group projects without shared canonical key", () => { - const local = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - const remote = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); - }); - - it("uses per-project overrides from settings", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); - expect( - deriveLogicalProjectKeyFromSettings(project, { - ...DEFAULT_GROUPING_SETTINGS, - sidebarProjectGroupingOverrides: { - [derivePhysicalProjectKey(project)]: "separate", - }, - }), - ).toBe(derivePhysicalProjectKey(project)); - }); + expect(deriveLogicalProjectKey(primary)).toBe(repositoryIdentity.canonicalKey); + expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); - describe("selectProjectsAcrossEnvironments", () => { - it("returns all projects from all environments", () => { - const state = makeFixtureState(); - const projects = selectProjectsAcrossEnvironments(state); - expect(projects).toHaveLength(4); - const names = projects.map((p) => p.name).toSorted(); - expect(names).toEqual(["local-only", "remote-only", "shared-repo", "shared-repo"]); + it("keeps projects without repository identity physically scoped", () => { + const primary = makeProject(); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, }); - }); - describe("selectSidebarThreadsAcrossEnvironments", () => { - it("returns all sidebar thread summaries from all environments", () => { - const state = makeFixtureState(); - const threads = selectSidebarThreadsAcrossEnvironments(state); - expect(threads).toHaveLength(5); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - expect(ids).toContain(threadL1); - expect(ids).toContain(threadRO1); - }); + expect(deriveLogicalProjectKey(primary)).toBe(derivePhysicalProjectKey(primary)); + expect(deriveLogicalProjectKey(remote)).toBe(derivePhysicalProjectKey(remote)); + expect(deriveLogicalProjectKey(primary)).not.toBe(deriveLogicalProjectKey(remote)); }); - describe("selectSidebarThreadsForProjectRef", () => { - it("returns threads for a single project ref", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(primaryEnvId, sharedProjectPrimaryId); - const threads = selectSidebarThreadsForProjectRef(state, ref); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns empty array for null ref", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRef(state, null)).toEqual([]); - }); + it("uses the physical key when repository grouping is disabled", () => { + const project = makeProject({ repositoryIdentity }); - it("returns empty array for nonexistent environment", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(EnvironmentId.make("nonexistent"), sharedProjectPrimaryId); - expect(selectSidebarThreadsForProjectRef(state, ref)).toEqual([]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }), + ).toBe(derivePhysicalProjectKey(project)); }); - describe("selectSidebarThreadsForProjectRefs", () => { - it("returns empty for empty refs", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRefs(state, [])).toEqual([]); - }); - - it("returns threads for a single ref", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, sharedProjectPrimaryId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns combined threads from multiple refs across environments", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(remoteEnvId, sharedProjectRemoteId), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(3); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - }); - - it("returns threads from remote-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(remoteEnvId, remoteOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadRO1); - }); - - it("returns threads from local-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, localOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadL1); - }); + it("allows a per-project override to separate an otherwise grouped repository", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - it("handles refs with nonexistent environment gracefully", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(EnvironmentId.make("nonexistent"), ProjectId.make("nope")), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - // Only returns threads from the valid ref - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe(physicalKey); }); - describe("logical project grouping for sidebar", () => { - it("computes correct logical key for grouped projects and aggregates threads", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - // Group by logical key - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Shared project should be grouped - const sharedGroup = groups.get(SHARED_REPO_CANONICAL_KEY); - expect(sharedGroup).toBeDefined(); - expect(sharedGroup).toHaveLength(2); - expect(sharedGroup!.map((p) => p.environmentId).toSorted()).toEqual( - [primaryEnvId, remoteEnvId].toSorted(), - ); - - // Build member refs for the grouped project and fetch threads - const memberRefs = sharedGroup!.map((p) => scopeProjectRef(p.environmentId, p.id)); - const threads = selectSidebarThreadsForProjectRefs(state, memberRefs); - expect(threads).toHaveLength(3); - const threadIds = threads.map((t) => t.id); - expect(threadIds).toContain(threadP1); - expect(threadIds).toContain(threadP2); - expect(threadIds).toContain(threadR1); - }); + it("allows a per-project override to group a repository while the global mode is separate", () => { + const project = makeProject({ repositoryIdentity }); - it("local-only and remote-only projects remain ungrouped", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Should have 3 groups total: shared, local-only, remote-only - expect(groups.size).toBe(3); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "repository", + }, + }), + ).toBe(repositoryIdentity.canonicalKey); + }); - // Local-only group - const localKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === localOnlyProjectId)!, - ); - expect(groups.get(localKey)).toHaveLength(1); + it("reports the effective grouping mode after applying an override", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - // Remote-only group - const remoteKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === remoteOnlyProjectId)!, - ); - expect(groups.get(remoteKey)).toHaveLength(1); - }); + expect(resolveProjectGroupingMode(project, defaultGroupingSettings)).toBe("repository"); + expect( + resolveProjectGroupingMode(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe("separate"); }); }); diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index db4406ecee0..eb818e8f558 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -2,11 +2,10 @@ import { attachEnvironmentDescriptor, createKnownEnvironment, type KnownEnvironment, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +} from "@t3tools/client-runtime/environment"; +import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HttpClientError } from "effect/unstable/http"; -import { create } from "zustand"; import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; @@ -14,18 +13,7 @@ import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; import { readPrimaryEnvironmentTarget } from "./target"; -interface PrimaryEnvironmentBootstrapState { - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; - readonly reset: () => void; -} - -const usePrimaryEnvironmentBootstrapStore = create()((set) => ({ - descriptor: null, - setDescriptor: (descriptor) => set({ descriptor }), - reset: () => set({ descriptor: null }), -})); - +let primaryEnvironmentDescriptor: ExecutionEnvironmentDescriptor | null = null; let primaryEnvironmentDescriptorPromise: Promise | null = null; function createPrimaryKnownEnvironment(input: { @@ -72,17 +60,13 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise state.descriptor?.environmentId ?? null); + return primaryEnvironmentDescriptor; } export function writePrimaryEnvironmentDescriptor( descriptor: ExecutionEnvironmentDescriptor | null, ): void { - usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); + primaryEnvironmentDescriptor = descriptor; } export function getPrimaryKnownEnvironment(): KnownEnvironment | null { @@ -118,7 +102,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise void = () => { - throw new Error("Registry read resolver was not initialized."); -}; - -describe("environment runtime catalog stores", () => { - beforeEach(async () => { - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - }); - - afterEach(async () => { - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - vi.unstubAllGlobals(); - }); - - it("resets the saved environment registry store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }); - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRegistryStoreForTests(); - - expect(useSavedEnvironmentRegistryStore.getState().byId).toEqual({}); - }); - - it("resets the saved environment runtime store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - connectedAt: "2026-04-09T00:00:00.000Z", - }); - - expect(useSavedEnvironmentRuntimeStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRuntimeStoreForTests(); - - expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); - }); - - it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { - let storedSecret: string | null = "legacy-bearer-token"; - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => storedSecret, - setSavedEnvironmentSecret: async (_environmentId, secret) => { - storedSecret = secret; - return true; - }, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - const environmentId = EnvironmentId.make("environment-1"); - - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "bearer", - token: "legacy-bearer-token", - }); - await expect( - writeSavedEnvironmentCredential(environmentId, { - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }), - ).resolves.toBe(true); - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }); - }); - - it("does not throw when local api lookup fails during registry persistence", async () => { - vi.unstubAllGlobals(); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - expect(() => - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }), - ).not.toThrow(); - - expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error)); - }); - - it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - resolveRegistryRead = () => { - throw new Error("Registry read resolver was not initialized."); - }; - - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: () => - new Promise((resolve) => { - resolveRegistryRead = () => resolve([]); - }), - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - const hydrationPromise = waitForSavedEnvironmentRegistryHydration(); - - const environmentId = EnvironmentId.make("environment-1"); - const record = { - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - } as const; - - useSavedEnvironmentRegistryStore.getState().upsert(record); - - resolveRegistryRead(); - await hydrationPromise; - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); - }); -}); diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts deleted file mode 100644 index 570d9753c13..00000000000 --- a/apps/web/src/environments/runtime/catalog.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; -import type { - AuthEnvironmentScope, - EnvironmentId, - ExecutionEnvironmentDescriptor, - PersistedSavedEnvironmentRecord, - ServerConfig, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { create } from "zustand"; - -import { ensureLocalApi } from "../../localApi"; -import { getPrimaryKnownEnvironment } from "../primary"; - -export interface SavedEnvironmentRecord { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly createdAt: string; - readonly lastConnectedAt: string | null; - readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; - readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; -} - -export const SavedEnvironmentCredential = Schema.Union([ - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("bearer"), - token: Schema.String, - }), - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("dpop"), - accessToken: Schema.String, - }), -]); -export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; - -const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); -const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( - SavedEnvironmentCredentialJson, -); -const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); - -interface SavedEnvironmentRegistryState { - readonly byId: Record; -} - -interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { - readonly upsert: (record: SavedEnvironmentRecord) => void; - readonly remove: (environmentId: EnvironmentId) => void; - readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; - readonly rename: (environmentId: EnvironmentId, label: string) => void; - readonly reset: () => void; -} - -let savedEnvironmentRegistryHydrated = false; -let savedEnvironmentRegistryHydrationPromise: Promise | null = null; - -export function toPersistedSavedEnvironmentRecord( - record: SavedEnvironmentRecord, -): PersistedSavedEnvironmentRecord { - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; -} - -function valuesOfSavedEnvironmentRegistry( - byId: Record, -): ReadonlyArray { - return Object.values(byId) as ReadonlyArray; -} - -function persistSavedEnvironmentRegistryState( - byId: Record, -): void { - try { - void ensureLocalApi() - .persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((record) => - toPersistedSavedEnvironmentRecord(record), - ), - ) - .catch((error) => { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - }); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - } -} - -function replaceSavedEnvironmentRegistryState( - records: ReadonlyArray, -): void { - const currentById = useSavedEnvironmentRegistryStore.getState().byId; - const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); - useSavedEnvironmentRegistryStore.setState({ - byId: { - ...hydratedById, - ...currentById, - }, - }); -} - -async function hydrateSavedEnvironmentRegistry(): Promise { - if (savedEnvironmentRegistryHydrated) { - return; - } - if (savedEnvironmentRegistryHydrationPromise) { - return savedEnvironmentRegistryHydrationPromise; - } - - const nextHydration = (async () => { - try { - const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry(); - replaceSavedEnvironmentRegistryState(persistedRecords); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] hydrate failed", error); - } finally { - savedEnvironmentRegistryHydrated = true; - } - })(); - - const hydrationPromise = nextHydration.finally(() => { - if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) { - savedEnvironmentRegistryHydrationPromise = null; - } - }); - savedEnvironmentRegistryHydrationPromise = hydrationPromise; - - return savedEnvironmentRegistryHydrationPromise; -} - -export const useSavedEnvironmentRegistryStore = create()((set) => ({ - byId: {}, - upsert: (record) => - set((state) => { - const byId = { - ...state.byId, - [record.environmentId]: record, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - remove: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - persistSavedEnvironmentRegistryState(remaining); - return { - byId: remaining, - }; - }), - markConnected: (environmentId, connectedAt) => - set((state) => { - const existing = state.byId[environmentId]; - if (!existing) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - lastConnectedAt: connectedAt, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - rename: (environmentId, label) => - set((state) => { - const existing = state.byId[environmentId]; - const nextLabel = label.trim(); - if (!existing || nextLabel.length === 0 || existing.label === nextLabel) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - label: nextLabel, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - reset: () => { - persistSavedEnvironmentRegistryState({}); - set({ - byId: {}, - }); - }, -})); - -export function hasSavedEnvironmentRegistryHydrated(): boolean { - return savedEnvironmentRegistryHydrated; -} - -export function waitForSavedEnvironmentRegistryHydration(): Promise { - if (hasSavedEnvironmentRegistryHydrated()) { - return Promise.resolve(); - } - - return hydrateSavedEnvironmentRegistry(); -} - -export function listSavedEnvironmentRecords(): ReadonlyArray { - return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) => - left.label.localeCompare(right.label), - ); -} - -export function getSavedEnvironmentRecord( - environmentId: EnvironmentId, -): SavedEnvironmentRecord | null { - return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null; -} - -export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { - const primaryEnvironment = getPrimaryKnownEnvironment(); - if (primaryEnvironment?.environmentId === environmentId) { - return getKnownEnvironmentHttpBaseUrl(primaryEnvironment); - } - - return getSavedEnvironmentRecord(environmentId)?.httpBaseUrl ?? null; -} - -export function resolveEnvironmentHttpUrl(input: { - readonly environmentId: EnvironmentId; - readonly pathname: string; - readonly searchParams?: Record; -}): string { - const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId); - if (!httpBaseUrl) { - throw new Error(`Unable to resolve HTTP base URL for environment ${input.environmentId}.`); - } - - const url = new URL(httpBaseUrl); - url.pathname = input.pathname; - if (input.searchParams) { - url.search = new URLSearchParams(input.searchParams).toString(); - } - return url.toString(); -} - -export function resetSavedEnvironmentRegistryStoreForTests() { - savedEnvironmentRegistryHydrated = false; - savedEnvironmentRegistryHydrationPromise = null; - useSavedEnvironmentRegistryStore.setState({ byId: {} }); -} - -export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise { - const byId = { - ...useSavedEnvironmentRegistryStore.getState().byId, - [record.environmentId]: record, - }; - - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); -} - -export async function readSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); -} - -export async function readSavedEnvironmentCredential( - environmentId: EnvironmentId, -): Promise { - const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); - if (!secret) { - return null; - } - const decoded = decodeSavedEnvironmentCredentialJson(secret); - if (Option.isSome(decoded)) { - return decoded.value; - } - // Legacy bearer secrets were stored directly as strings. - return { version: 1, method: "bearer", token: secret }; -} - -export async function writeSavedEnvironmentCredential( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret( - environmentId, - encodeSavedEnvironmentCredentialJson(credential), - ); -} - -export async function writeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, - bearerToken: string, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); -} - -export async function removeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); -} - -export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; - -export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown"; - -export interface SavedEnvironmentRuntimeState { - readonly connectionState: SavedEnvironmentConnectionState; - readonly authState: SavedEnvironmentAuthState; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly scopes: ReadonlyArray | null; - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly serverConfig: ServerConfig | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; -} - -interface SavedEnvironmentRuntimeStoreState { - readonly byId: Record; - readonly ensure: (environmentId: EnvironmentId) => void; - readonly patch: ( - environmentId: EnvironmentId, - patch: Partial, - ) => void; - readonly clear: (environmentId: EnvironmentId) => void; - readonly reset: () => void; -} - -const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Object.freeze({ - connectionState: "disconnected", - authState: "unknown", - lastError: null, - lastErrorAt: null, - scopes: null, - descriptor: null, - serverConfig: null, - connectedAt: null, - disconnectedAt: null, -}); - -function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState { - return { - ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE, - }; -} - -export const useSavedEnvironmentRuntimeStore = create()( - (set) => ({ - byId: {}, - ensure: (environmentId) => - set((state) => { - if (state.byId[environmentId]) { - return state; - } - return { - byId: { - ...state.byId, - [environmentId]: createDefaultSavedEnvironmentRuntimeState(), - }, - }; - }), - patch: (environmentId, patch) => - set((state) => ({ - byId: { - ...state.byId, - [environmentId]: { - ...(state.byId[environmentId] ?? createDefaultSavedEnvironmentRuntimeState()), - ...patch, - }, - }, - })), - clear: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - return { - byId: remaining, - }; - }), - reset: () => - set({ - byId: {}, - }), - }), -); - -export function getSavedEnvironmentRuntimeState( - environmentId: EnvironmentId, -): SavedEnvironmentRuntimeState { - return ( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ?? - DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE - ); -} - -export function resetSavedEnvironmentRuntimeStoreForTests() { - useSavedEnvironmentRuntimeStore.getState().reset(); -} diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts deleted file mode 100644 index 392db299339..00000000000 --- a/apps/web/src/environments/runtime/connection.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createEnvironmentConnection } from "./connection"; -import type { WsRpcClient } from "@t3tools/client-runtime"; - -function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { - const lifecycleListeners = new Set<(event: any) => void>(); - const configListeners = new Set<(event: any) => void>(); - const shellListeners = new Set<(event: any) => void>(); - let shellResubscribe: (() => void) | undefined; - - const client = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - shellResubscribe?.(); - }), - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-1"), - }, - })), - subscribeConfig: vi.fn((listener: (event: any) => void) => { - configListeners.add(listener); - return () => configListeners.delete(listener); - }), - subscribeLifecycle: vi.fn((listener: (event: any) => void) => { - lifecycleListeners.add(listener); - return () => lifecycleListeners.delete(listener); - }), - subscribeAuthAccess: () => () => undefined, - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - subscribeShell: vi.fn( - (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { - shellListeners.add(listener); - shellResubscribe = options?.onResubscribe; - if (config?.emitInitialSnapshot !== false) { - queueMicrotask(() => { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - }); - } - return () => { - shellListeners.delete(listener); - if (shellResubscribe === options?.onResubscribe) { - shellResubscribe = undefined; - } - }; - }, - ), - subscribeThread: vi.fn(() => () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - attach: vi.fn(() => () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - runStackedAction: vi.fn(async () => ({}) as any), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - review: { - getDiffPreview: vi.fn(async () => undefined), - }, - } as unknown as WsRpcClient; - - return { - client, - emitWelcome: (environmentId: EnvironmentId) => { - for (const listener of lifecycleListeners) { - listener({ - type: "welcome", - payload: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitConfigSnapshot: (environmentId: EnvironmentId) => { - for (const listener of configListeners) { - listener({ - type: "snapshot", - config: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitShellSnapshot: (snapshotSequence: number) => { - for (const listener of shellListeners) { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - } - }, - }; -} - -describe("createEnvironmentConnection", () => { - it("bootstraps from the shell subscription snapshot", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - expect(syncShellSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ snapshotSequence: 1 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("rejects welcome/config identity drift", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitWelcome } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - - expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( - "Environment connection env-1 changed identity to env-2 via server lifecycle welcome.", - ); - - await connection.dispose(); - }); - - it("waits for a fresh shell snapshot after reconnect", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitShellSnapshot } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - const reconnectPromise = connection.reconnect(); - await Promise.resolve(); - expect(syncShellSnapshot).toHaveBeenCalledTimes(1); - - emitShellSnapshot(2); - await reconnectPromise; - - expect(client.reconnect).toHaveBeenCalledTimes(1); - expect(syncShellSnapshot).toHaveBeenCalledTimes(2); - expect(syncShellSnapshot).toHaveBeenLastCalledWith( - expect.objectContaining({ snapshotSequence: 2 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "primary", - knownEnvironment: { - id: "env-1", - label: "Local env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); - - expect(client.server.subscribeLifecycle).not.toHaveBeenCalled(); - expect(client.server.subscribeConfig).not.toHaveBeenCalled(); - expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce(); - - await connection.dispose(); - }); - - it("rejects bootstrap waits when a pending connection is disposed", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient({ emitInitialSnapshot: false }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - const pendingBootstrap = connection.ensureBootstrapped(); - - await connection.dispose(); - - await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping"); - }); -}); diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts deleted file mode 100644 index cb1c606b435..00000000000 --- a/apps/web/src/environments/runtime/connection.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - EnvironmentConnectionDisposedError, - type EnvironmentConnection, -} from "@t3tools/client-runtime"; diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts deleted file mode 100644 index 7333e03a42a..00000000000 --- a/apps/web/src/environments/runtime/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export { - getEnvironmentHttpBaseUrl, - getSavedEnvironmentRecord, - getSavedEnvironmentRuntimeState, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - resolveEnvironmentHttpUrl, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, -} from "./catalog"; - -export { - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, - requireEnvironmentConnection, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "./service"; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts deleted file mode 100644 index a9da256f8fe..00000000000 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ /dev/null @@ -1,1111 +0,0 @@ -import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Tracer from "effect/Tracer"; -import { Headers } from "effect/unstable/http"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; - -const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); - -let mockSavedRecords: Array> = []; - -const mockResolveRemotePairingTarget = vi.fn(); -const mockFetchRemoteEnvironmentDescriptor = vi.fn(); -const mockBootstrapRemoteBearerSession = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockFetchRemoteDpopSessionState = vi.fn(); -const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); -const mockWsTransportConnectors: Array<() => Promise> = []; -let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; -let mockRelayClientTracer = Option.none(); -const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => - Effect.runPromise( - effect.pipe( - Effect.provideService( - managedRelayDpopSigner, - managedRelayDpopSigner.of({ - thumbprint: Effect.succeed("thumbprint"), - createProof: () => Effect.succeed("dpop-proof"), - }), - ), - Effect.provideService(RelayClientTracer, mockRelayClientTracer), - ), - ), -); -const mockBootstrapSshBearerSession = vi.fn(); -const mockFetchSshSessionState = vi.fn(); -const mockPersistSavedEnvironmentRecord = vi.fn(); -const mockWriteSavedEnvironmentBearerToken = vi.fn(); -const mockWriteSavedEnvironmentCredential = vi.fn(); -const mockSetSavedEnvironmentRegistry = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { - return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; -}); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockRemoveSavedEnvironmentBearerToken = vi.fn(); -const mockPatchRuntime = vi.fn(); -const mockClearRuntime = vi.fn(); -const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { - mockSavedRecords = Object.values(next.byId); -}); -const mockRemove = vi.fn((environmentId: EnvironmentId) => { - mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); -}); -const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, - ); -}); -const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, label } : record, - ); -}); -const mockUpsert = vi.fn((record: Record) => { - mockSavedRecords = [ - ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), - record, - ]; -}); -const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); -const mockEnsureSshEnvironment = vi.fn(); -const mockDisconnectSshEnvironment = vi.fn(); -const mockFetchSshEnvironmentDescriptor = vi.fn(); -const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); -const mockCreateEnvironmentConnection = vi.fn(); -const mockClientGetConfig = vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, -})); -const mockConnectManagedCloudEnvironment = vi.fn(); -const mockReadManagedRelayClerkToken = vi.fn(); - -vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ - ...(await importOriginal()), - resolveRemotePairingTarget: mockResolveRemotePairingTarget, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("../../cloud/linkEnvironment", () => ({ - connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, -})); - -vi.mock("../../cloud/managedAuth", () => ({ - readManagedRelayClerkToken: mockReadManagedRelayClerkToken, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: () => ({ - persistence: { - setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, - }, - }), -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, - toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore: { - getState: () => ({ - upsert: mockUpsert, - remove: mockRemove, - markConnected: mockMarkConnected, - rename: mockRename, - }), - setState: mockRegistrySetState, - subscribe: vi.fn(() => () => {}), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: mockPatchRuntime, - clear: mockClearRuntime, - }), - }, - waitForSavedEnvironmentRegistryHydration: vi.fn(), - writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - managedRelayDpopSigner = actual.ManagedRelayDpopSigner; - return { - ...actual, - bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, - createWsRpcClient: vi.fn(() => ({ - server: { - getConfig: mockClientGetConfig, - }, - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - orchestration: { - subscribeThread: vi.fn(() => () => {}), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - })), - fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, - fetchRemoteSessionState: mockFetchRemoteSessionState, - fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, - resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: vi.fn(function WsTransport(connect: () => Promise) { - mockWsTransportConnectors.push(connect); - return {}; - }), -})); - -describe("addSavedEnvironment", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - mockSavedRecords = []; - mockRelayClientTracer = Option.none(); - mockWsTransportConnectors.length = 0; - vi.stubGlobal("window", { - desktopBridge: { - ensureSshEnvironment: mockEnsureSshEnvironment, - disconnectSshEnvironment: mockDisconnectSshEnvironment, - fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, - bootstrapSshBearerSession: mockBootstrapSshBearerSession, - fetchSshSessionState: mockFetchSshSessionState, - issueSshWebSocketTicket: vi.fn(), - }, - }); - mockResolveRemotePairingTarget.mockImplementation( - (input: { host?: string; pairingCode?: string }) => ({ - httpBaseUrl: input.host - ? input.host.endsWith("/") - ? input.host - : `${input.host}/` - : "https://remote.example.com/", - wsBaseUrl: input.host - ? input.host.replace(/^http/u, "ws").endsWith("/") - ? input.host.replace(/^http/u, "ws") - : `${input.host.replace(/^http/u, "ws")}/` - : "wss://remote.example.com/", - credential: input.pairingCode ?? "pairing-code", - }), - ); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteEnvironmentDescriptor.mockReturnValue( - Effect.succeed({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }), - ); - mockBootstrapRemoteBearerSession.mockReturnValue( - Effect.succeed({ - access_token: "bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }), - ); - mockFetchRemoteSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockFetchRemoteDpopSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), - ); - mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"), - ); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }); - mockBootstrapSshBearerSession.mockResolvedValue({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - mockWriteSavedEnvironmentCredential.mockResolvedValue(true); - mockReadManagedRelayClerkToken.mockResolvedValue(null); - mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); - mockFetchSshSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved", - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - ); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, - }); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: "ssh-pairing-code", - }); - mockDisconnectSshEnvironment.mockResolvedValue(undefined); - }); - - it("rolls back persisted metadata when bearer token persistence fails", async () => { - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "bearer-token", - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(mockUpsert).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("restores unrelated saved environments when credential persistence rollback runs", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-existing"), - label: "Existing environment", - httpBaseUrl: "https://existing.example.com/", - wsBaseUrl: "wss://existing.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-existing"), - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("persists the server label after saved environment metadata refresh", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }, - }); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "100.65.180.100", - host: "remote.example.com", - pairingCode: "123456", - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockRename).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "Julius's Mac mini", - ); - expect(mockSavedRecords).toEqual([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("installs relay-managed environments with versioned DPoP credentials", async () => { - const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addManagedRelayEnvironment({ - environmentId: EnvironmentId.make("environment-1"), - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "managed-access-token", - relayTraceHeaders: Headers.empty, - }); - - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - { - version: 1, - method: "dpop", - accessToken: "managed-access-token", - }, - ); - expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ - httpBaseUrl: "https://managed.example.com/", - accessToken: "managed-access-token", - dpopProof: "dpop-proof", - }); - await resetEnvironmentServiceForTests(); - }); - - it("renews expired managed DPoP credentials through the relay", async () => { - const environmentId = EnvironmentId.make("environment-1"); - const productSpans: Array = []; - mockRelayClientTracer = Option.some( - Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - productSpans.push(span); - return span; - }, - }), - ); - const relayTraceHeaders = Headers.fromInput({ - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }); - mockSavedRecords = [ - { - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - createdAt: "2026-05-25T00:00:00.000Z", - lastConnectedAt: null, - relayManaged: { relayUrl: "https://relay.example.com" }, - }, - ]; - mockReadSavedEnvironmentCredential.mockResolvedValue({ - version: 1, - method: "dpop", - accessToken: "expired-access-token", - }); - mockFetchRemoteDpopSessionState - .mockReturnValueOnce( - Effect.fail( - decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-expired", - }), - ), - ) - .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); - mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); - mockConnectManagedCloudEnvironment.mockReturnValue( - Effect.succeed({ - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "renewed-access-token", - relayTraceHeaders, - }), - ); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - await reconnectSavedEnvironment(environmentId); - - expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - relayUrl: "https://relay.example.com", - environment: expect.objectContaining({ environmentId }), - }); - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { - version: 1, - method: "dpop", - accessToken: "renewed-access-token", - }); - const renewedTransportConnector = mockWsTransportConnectors.at(-1); - expect(renewedTransportConnector).toBeDefined(); - await renewedTransportConnector!(); - expect(mockResolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: "wss://managed.example.com/", - httpBaseUrl: "https://managed.example.com/", - accessToken: "renewed-access-token", - dpopProof: "dpop-proof", - }); - expect(productSpans.some((span) => span.name === "relay.environment.reconnect")).toBe(false); - await resetEnvironmentServiceForTests(); - }); - - it("removes an older ssh record when the same target returns a new environment id", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-2"), - label: "Remote environment", - }); - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Old ssh environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-2"), - }); - - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-2"), - }), - ); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); - expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); - - await resetEnvironmentServiceForTests(); - }); - - it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - const authError = decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-test", - }); - mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Saved environment credential expired. Pair it again."); - - expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const createdConnections: Array<{ - readonly environmentId: EnvironmentId; - readonly dispose: ReturnType; - }> = []; - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { - const connection = { - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: vi.fn(async () => undefined), - }; - createdConnections.push(connection); - return connection; - }, - ); - - const { - connectDesktopSshEnvironment, - listEnvironmentConnections, - resetEnvironmentServiceForTests, - } = await import("./service"); - - await connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }); - - expect(createdConnections).toHaveLength(2); - expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); - expect(listEnvironmentConnections()).toHaveLength(1); - expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); - - await resetEnvironmentServiceForTests(); - }); - - it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const connection = { - kind: "saved" as const, - environmentId: EnvironmentId.make("environment-1"), - knownEnvironment: { - environmentId: EnvironmentId.make("environment-1"), - }, - client: { - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: vi.fn(async () => { - throw new Error("socket closed"); - }), - dispose: async () => undefined, - }; - mockCreateEnvironmentConnection.mockReturnValue(connection); - - const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }); - - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Unable to persist saved environment credentials.", - ); - - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "Unable to persist saved environment credentials.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("bootstraps a desktop ssh environment through the desktop bridge", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }, - { issuePairingToken: true }, - ); - expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - }); - expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); - expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); - expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await removeSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( - mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects a saved ssh environment without removing its saved record", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("cancels a pending saved environment connection when disconnected", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - const dispose = vi.fn(async () => undefined); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose, - }), - ); - let resolveSessionState!: (value: { - readonly authenticated: true; - readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; - }) => void; - mockFetchRemoteSessionState.mockReturnValue( - Effect.promise( - () => - new Promise((resolve) => { - resolveSessionState = resolve; - }), - ), - ); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - await vi.waitFor(() => { - expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce(); - }); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - resolveSessionState({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - await expect(reconnectPromise).resolves.toBeUndefined(); - - expect(listEnvironmentConnections()).toHaveLength(0); - expect(dispose).toHaveBeenCalledOnce(); - expect(mockPatchRuntime).not.toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - { issuePairingToken: true }, - ); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "ssh-bearer-token", - ); - - await resetEnvironmentServiceForTests(); - }); - - it("rolls back ssh registry metadata when pairing token issuance fails", async () => { - const originalRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }; - mockSavedRecords = [originalRecord]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: null, - }); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Desktop SSH launch did not return a pairing token.", - ); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith( - expect.objectContaining({ - httpBaseUrl: "http://127.0.0.1:3774/", - }), - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]); - expect(mockSavedRecords).toEqual([originalRecord]); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms.")); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "SSH command timed out after 60000ms.", - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "connecting", - }), - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "SSH command timed out after 60000ms.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts deleted file mode 100644 index 592bc31e260..00000000000 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: vi.fn(() => ({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - })), -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createWsRpcClient: mockCreateWsRpcClient, - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -vi.mock("~/composerDraftStore", () => ({ - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - useComposerDraftStore: { - getState: () => ({ - getDraftThreadByRef: vi.fn(() => null), - clearDraftThread: vi.fn(), - }), - }, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => ({ - persistence: { - setSavedEnvironmentRegistry: vi.fn(async () => undefined), - }, - })), -})); - -vi.mock("~/lib/terminalStateCleanup", () => ({ - collectActiveTerminalThreadIds: vi.fn(() => []), -})); - -vi.mock("~/orchestrationEventEffects", () => ({ - deriveOrchestrationBatchEffects: vi.fn(() => ({ - promotedThreadRefs: [], - invalidatedProviderState: false, - })), -})); - -vi.mock("~/store", () => ({ - useStore: { - getState: () => ({ - syncServerShellSnapshot: vi.fn(), - syncServerThreadDetail: vi.fn(), - removeServerThreadDetail: vi.fn(), - applyServerShellEvent: vi.fn(), - }), - }, - selectProjectsAcrossEnvironments: vi.fn(() => []), - selectSidebarThreadSummaryByRef: vi.fn(() => null), - selectThreadByRef: vi.fn(() => null), - selectThreadsAcrossEnvironments: vi.fn(() => []), -})); - -vi.mock("~/terminalStateStore", () => ({ - useTerminalStateStore: { - getState: () => ({ - applyTerminalEvent: vi.fn(), - removeTerminalState: vi.fn(), - clearTerminalSelection: vi.fn(), - }), - }, -})); - -vi.mock("~/uiStateStore", () => ({ - useUiStateStore: { - getState: () => ({ - clearThreadUi: vi.fn(), - syncPromotedDraftThreadRefs: vi.fn(), - }), - }, -})); - -const savedRecord = { - environmentId: EnvironmentId.make("env-saved"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.test/", - wsBaseUrl: "wss://remote.example.test/", -}; - -const configSnapshot = { - environment: { - environmentId: savedRecord.environmentId, - label: "Remote environment", - }, -}; - -function createClient() { - return { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - server: { - getConfig: vi.fn(async () => configSnapshot), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: vi.fn(() => () => undefined), - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - subscribePorts: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - pull: vi.fn(async () => undefined), - refreshStatus: vi.fn(async () => undefined), - onStatus: vi.fn(() => () => undefined), - runStackedAction: vi.fn(async () => ({})), - listBranches: vi.fn(async () => []), - createWorktree: vi.fn(async () => undefined), - removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), - init: vi.fn(async () => undefined), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - }; -} - -describe("saved environment startup", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => - environmentId === savedRecord.environmentId ? savedRecord : null, - ); - mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]); - mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockCreateWsRpcClient.mockImplementation(() => createClient()); - mockCreateEnvironmentConnection.mockImplementation((input) => { - if (input.kind === "saved") { - queueMicrotask(() => { - input.onConfigSnapshot?.(configSnapshot); - }); - } - - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - dispose: vi.fn(async () => undefined), - }; - }); - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.useRealTimers(); - }); - - it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => { - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - await vi.runAllTimersAsync(); - - const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCall).toBeDefined(); - - const savedClient = savedConnectionCall?.[0]?.client; - expect(savedClient.server.getConfig).not.toHaveBeenCalled(); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => { - let finishHydration!: () => void; - let finishTokenRead!: (token: string) => void; - - mockWaitForSavedEnvironmentRegistryHydration.mockImplementation( - () => - new Promise((resolve) => { - finishHydration = () => resolve(); - }), - ); - mockReadSavedEnvironmentBearerToken.mockImplementation( - () => - new Promise((resolve) => { - finishTokenRead = resolve; - }), - ); - - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0]; - expect(registryListener).toBeTypeOf("function"); - - registryListener?.(); - finishHydration(); - await vi.waitFor(() => { - expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1); - }); - - finishTokenRead("saved-bearer-token"); - await vi.runAllTimersAsync(); - - const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCalls).toHaveLength(1); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts deleted file mode 100644 index 8d7cec99043..00000000000 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ /dev/null @@ -1,667 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockSubscribeThread = vi.fn(); -const mockThreadUnsubscribe = vi.fn(); -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockConnectionReconnects: Array> = []; -let savedEnvironmentRegistryListener: (() => void) | null = null; - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - const stubWsClient: WsRpcClient = { - dispose: async () => undefined, - reconnect: async () => undefined, - isHeartbeatFresh: () => false, - cloud: { - getRelayClientStatus: vi.fn(), - installRelayClient: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - getArchivedShellSnapshot: vi.fn(), - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: mockSubscribeThread, - }, - terminal: { - open: vi.fn(), - attach: vi.fn(() => () => undefined), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - preview: { - open: vi.fn(), - navigate: vi.fn(), - refresh: vi.fn(), - close: vi.fn(), - list: vi.fn(), - reportStatus: vi.fn(), - automation: { - connect: vi.fn(() => () => undefined), - respond: vi.fn(), - reportOwner: vi.fn(), - clearOwner: vi.fn(), - }, - onEvent: vi.fn(() => () => undefined), - subscribePorts: vi.fn(() => () => undefined), - }, - projects: { - listEntries: vi.fn(), - readFile: vi.fn(), - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - assets: { createUrl: vi.fn() }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn(() => () => undefined), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - discoverSourceControl: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - removeKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - getTraceDiagnostics: vi.fn(), - getProcessDiagnostics: vi.fn(), - getProcessResourceHistory: vi.fn(), - signalProcess: vi.fn(), - }, - }; - return { - ...actual, - createWsRpcClient: vi.fn(() => stubWsClient), - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -function makeThreadShellSnapshot(params: { - readonly threadId: ThreadId; - readonly sessionStatus?: - | "idle" - | "starting" - | "running" - | "ready" - | "interrupted" - | "stopped" - | "error"; - readonly hasPendingApprovals?: boolean; - readonly hasPendingUserInput?: boolean; - readonly hasActionableProposedPlan?: boolean; -}): OrchestrationShellSnapshot { - const projectId = ProjectId.make("project-1"); - const turnId = TurnId.make("turn-1"); - - return { - snapshotSequence: 1, - projects: [], - updatedAt: "2026-04-13T00:00:00.000Z", - threads: [ - { - id: params.threadId, - projectId, - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - params.sessionStatus === "running" - ? { - turnId, - state: "running", - requestedAt: "2026-04-13T00:00:00.000Z", - startedAt: "2026-04-13T00:00:01.000Z", - completedAt: null, - assistantMessageId: null, - } - : null, - createdAt: "2026-04-13T00:00:00.000Z", - updatedAt: "2026-04-13T00:00:00.000Z", - archivedAt: null, - session: params.sessionStatus - ? { - threadId: params.threadId, - status: params.sessionStatus, - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: params.sessionStatus === "running" ? turnId : null, - lastError: null, - updatedAt: "2026-04-13T00:00:00.000Z", - } - : null, - latestUserMessageAt: null, - hasPendingApprovals: params.hasPendingApprovals ?? false, - hasPendingUserInput: params.hasPendingUserInput ?? false, - hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, - }, - ], - }; -} - -describe("retainThreadDetailSubscription", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - }); - - mockThreadUnsubscribe.mockImplementation(() => undefined); - mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => true), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - savedEnvironmentRegistryListener = null; - mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => { - savedEnvironmentRegistryListener = listener; - return () => { - if (savedEnvironmentRegistryListener === listener) { - savedEnvironmentRegistryListener = null; - } - }; - }); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockListSavedEnvironmentRecords.mockReturnValue([]); - mockGetSavedEnvironmentRecord.mockReturnValue(null); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read"], - }); - mockConnectionReconnects.length = 0; - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-1"); - - const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseFirst(); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseSecond(); - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(28 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("does not start the primary connection until the known environment has an id", async () => { - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - }); - const { - listEnvironmentConnections, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - - expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); - expect(listEnvironmentConnections()).toEqual([]); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-active"); - - const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; - expect(connectionInput).toBeDefined(); - - connectionInput.syncShellSnapshot( - makeThreadShellSnapshot({ - threadId, - sessionStatus: "ready", - hasPendingApprovals: true, - }), - environmentId, - ); - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - release(); - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - connectionInput.applyShellEvent( - { - kind: "thread-upserted", - sequence: 2, - thread: makeThreadShellSnapshot({ - threadId, - sessionStatus: "idle", - }).threads[0]!, - }, - environmentId, - ); - - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => { - const environmentId = EnvironmentId.make("env-remote"); - const threadId = ThreadId.make("thread-reconnect"); - const record = { - environmentId, - label: "Remote env", - httpBaseUrl: "http://remote.example.test", - wsBaseUrl: "ws://remote.example.test", - createdAt: "2026-05-01T00:00:00.000Z", - lastConnectedAt: "2026-05-01T00:00:00.000Z", - }; - mockListSavedEnvironmentRecords.mockReturnValue([record]); - mockGetSavedEnvironmentRecord.mockReturnValue(record); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - savedEnvironmentRegistryListener?.(); - await vi.waitFor(() => { - expect( - listEnvironmentConnections().some( - (connection) => connection.environmentId === environmentId, - ), - ).toBe(true); - }); - const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - await disconnectSavedEnvironment(environmentId); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - expect( - listEnvironmentConnections().some((connection) => connection.environmentId === environmentId), - ).toBe(false); - - const reconnectPromise = reconnectSavedEnvironment(environmentId); - await vi.advanceTimersByTimeAsync(200); - await reconnectPromise; - await vi.waitFor(() => { - expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( - createConnectionCallsBeforeReconnect + 1, - ); - expect(mockSubscribeThread).toHaveBeenCalledTimes(2); - }); - - release(); - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps healthy environment streams connected when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: { - ...input.client, - isHeartbeatFresh: vi.fn(() => true), - }, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reconnects stale environment streams when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => false), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("allows a larger idle cache before capacity eviction starts", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - - for (let index = 0; index < 12; index += 1) { - const release = retainThreadDetailSubscription( - environmentId, - ThreadId.make(`thread-${index + 1}`), - ); - release(); - } - - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("disposes cached thread detail subscriptions when the environment service resets", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-2"); - - const release = retainThreadDetailSubscription(environmentId, threadId); - release(); - - await resetEnvironmentServiceForTests(); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts deleted file mode 100644 index cbd0c996199..00000000000 --- a/apps/web/src/environments/runtime/service.ts +++ /dev/null @@ -1,2103 +0,0 @@ -import { - AuthEnvironmentScope, - type DesktopSshEnvironmentBootstrap, - type DesktopSshEnvironmentTarget, - type EnvironmentId, - type OrchestrationEvent, - type OrchestrationShellSnapshot, - type OrchestrationShellStreamEvent, - type ServerConfig, - EnvironmentAuthInvalidError, - ThreadId, -} from "@t3tools/contracts"; -import { - createWsRpcClient as createBaseWsRpcClient, - type WsRpcClient, - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, - fetchRemoteDpopSessionState, - fetchRemoteSessionState, - type ManagedRelayDpopProofInput, - ManagedRelayDpopSigner, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, -} from "@t3tools/client-runtime"; - -import { type QueryClient } from "@tanstack/react-query"; -import { Throttler } from "@tanstack/react-pacer"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { Headers, HttpTraceContext } from "effect/unstable/http"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { - createKnownEnvironment, - getKnownEnvironmentWsBaseUrl, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; - -import { - markPromotedDraftThreadByRef, - markPromotedDraftThreadsByRef, - useComposerDraftStore, -} from "~/composerDraftStore"; -import { ensureLocalApi } from "~/localApi"; -import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; -import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; -import { getPrimaryKnownEnvironment } from "../primary"; -import { webRuntime } from "../../lib/runtime"; -import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; -import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; - -import { - getSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - persistSavedEnvironmentRecord, - readSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken, - type SavedEnvironmentRecord, - type SavedEnvironmentCredential, - toPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential, -} from "./catalog"; -import { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - type EnvironmentConnection, -} from "./connection"; -import { - useStore, - selectProjectsAcrossEnvironments, - selectSidebarThreadSummaryByRef, - selectThreadByRef, - selectThreadsAcrossEnvironments, -} from "~/store"; -import { useTerminalUiStateStore } from "~/terminalUiStateStore"; -import { useUiStateStore } from "~/uiStateStore"; -import { getServerConfig } from "../../rpc/serverState"; -import { WsTransport } from "~/rpc/wsTransport"; -import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; -import { - deriveLogicalProjectKeyFromSettings, - derivePhysicalProjectKey, -} from "../../logicalProject"; - -const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); -import { getClientSettings } from "~/hooks/useSettings"; -import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; -import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; -import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; -import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; - -type EnvironmentServiceState = { - readonly queryClient: QueryClient; - readonly queryInvalidationThrottler: Throttler<() => void>; - refCount: number; - stop: () => void; -}; - -type ThreadDetailSubscriptionEntry = { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - unsubscribe: () => void; - unsubscribeConnectionListener: (() => void) | null; - refCount: number; - lastAccessedAt: number; - evictionTimeoutId: ReturnType | null; -}; - -const environmentConnections = new Map(); -const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); - -function isSavedEnvironmentConnectionCancelledError( - error: unknown, -): error is EnvironmentConnectionAttemptCancelledError { - return error instanceof EnvironmentConnectionAttemptCancelledError; -} - -interface PendingSavedEnvironmentConnection { - readonly isCurrent: () => boolean; - readonly promise: Promise; -} - -const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const pendingSavedEnvironmentConnections = new Map< - EnvironmentId, - PendingSavedEnvironmentConnection ->(); -const environmentConnectionListeners = new Set<() => void>(); -const providerInvalidationListeners = new Set<() => void>(); -const threadDetailSubscriptions = new Map(); -const lastAppliedProjectionVersionByEnvironment = new Map< - EnvironmentId, - { - readonly sequence: number; - readonly updatedAt: string | null; - } ->(); -const terminalMetadataSubscriptions = new Map void>(); -const portDiscoverySubscriptions = new Map void>(); - -let activeService: EnvironmentServiceState | null = null; -let needsProviderInvalidation = false; -let lastBrowserHiddenAt: number | null = null; -let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - -// TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): -// This file still owns web's legacy thread-detail subscription cache. Mobile -// uses createThreadDetailManager from @t3tools/client-runtime for the same -// retain/reconnect/evict lifecycle. When touching this logic, prefer migrating -// web to the shared manager or extracting the missing adapter layer instead of -// adding more behavior here. -// -// Thread detail subscription cache policy: -// - Active consumers keep a subscription retained via refCount. -// - Released subscriptions stay warm for a longer idle TTL to avoid churn -// while moving around the UI. -// - Threads with active work or pending user action are sticky and are never -// evicted while they remain non-idle. -// - Capacity eviction only targets idle cached subscriptions. -const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; -const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; -const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; -const NOOP = () => undefined; -const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; - -const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => - Effect.gen(function* () { - const signer = yield* ManagedRelayDpopSigner; - return yield* signer.createProof(input); - }); - -function createDeferredPromise() { - let resolve: ((value: T) => void) | null = null; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { - promise, - resolve: (value: T) => { - resolve?.(value); - resolve = null; - }, - }; -} - -async function waitForConfigSnapshot( - promise: Promise, - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs); - promise.then( - (config) => { - clearTimeout(timeoutId); - resolve(config); - }, - () => { - clearTimeout(timeoutId); - resolve(null); - }, - ); - }); -} - -function createSavedEnvironmentSyncScheduler() { - let activeSync: Promise | null = null; - let queued = false; - - const run = async (): Promise => { - do { - queued = false; - await syncSavedEnvironmentConnections(listSavedEnvironmentRecords()); - } while (queued); - }; - - return () => { - if (activeSync) { - queued = true; - return activeSync; - } - - activeSync = run() - .catch(() => undefined) - .finally(() => { - activeSync = null; - }); - - return activeSync; - }; -} -function compareAppliedProjectionVersion( - left: { readonly sequence: number; readonly updatedAt: string | null }, - right: { readonly sequence: number; readonly updatedAt: string | null }, -): number { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - - const leftUpdatedAt = left.updatedAt ?? ""; - const rightUpdatedAt = right.updatedAt ?? ""; - if (leftUpdatedAt === rightUpdatedAt) { - return 0; - } - - return leftUpdatedAt < rightUpdatedAt ? -1 : 1; -} - -function toAppliedProjectionVersion( - snapshot: Pick, -): { - readonly sequence: number; - readonly updatedAt: string; -} { - return { - sequence: snapshot.snapshotSequence, - updatedAt: snapshot.updatedAt, - }; -} - -export function shouldApplyProjectionSnapshot(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly next: Pick; -}): boolean { - if (input.current === null) { - return true; - } - - return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; -} - -export function shouldApplyProjectionEvent(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly sequence: number; -}): boolean { - if (input.current === null) { - return true; - } - - return input.sequence > input.current.sequence; -} - -function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { - readonly sequence: number; - readonly updatedAt: string | null; -} | null { - return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; -} - -function markAppliedProjectionSnapshot( - environmentId: EnvironmentId, - snapshot: Pick, -): void { - const nextVersion = toAppliedProjectionVersion(snapshot); - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if ( - currentVersion !== null && - compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 - ) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); -} - -function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if (currentVersion !== null && sequence <= currentVersion.sequence) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, { - sequence, - updatedAt: currentVersion?.updatedAt ?? null, - }); -} -function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { - return scopedThreadKey(scopeThreadRef(environmentId, threadId)); -} - -function clearThreadDetailSubscriptionEviction( - entry: ThreadDetailSubscriptionEntry, -): ThreadDetailSubscriptionEntry { - if (entry.evictionTimeoutId !== null) { - clearTimeout(entry.evictionTimeoutId); - entry.evictionTimeoutId = null; - } - return entry; -} - -function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); - const state = useStore.getState(); - const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); - - // Prefer shell/sidebar state first because it carries the coarse thread - // readiness flags used throughout the UI (pending approvals/input/plan). - if (sidebarThread) { - if ( - sidebarThread.hasPendingApprovals || - sidebarThread.hasPendingUserInput || - sidebarThread.hasActionableProposedPlan - ) { - return true; - } - - const orchestrationStatus = sidebarThread.session?.orchestrationStatus; - if ( - orchestrationStatus && - orchestrationStatus !== "idle" && - orchestrationStatus !== "stopped" - ) { - return true; - } - - if (sidebarThread.latestTurn?.state === "running") { - return true; - } - } - - const thread = selectThreadByRef(state, threadRef); - if (!thread) { - return false; - } - - const orchestrationStatus = thread.session?.orchestrationStatus; - return ( - Boolean( - orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", - ) || - thread.latestTurn?.state === "running" || - thread.pendingSourceProposedPlan !== undefined - ); -} - -function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); -} - -function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - if (entry.unsubscribeConnectionListener !== null) { - entry.unsubscribeConnectionListener(); - entry.unsubscribeConnectionListener = null; - } - if (entry.unsubscribe !== NOOP) { - return true; - } - - const connection = readEnvironmentConnection(entry.environmentId); - if (!connection) { - return false; - } - - entry.unsubscribe = connection.client.orchestration.subscribeThread( - { threadId: entry.threadId }, - (item) => { - if (item.kind === "snapshot") { - useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); - return; - } - applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); - }, - ); - return true; -} - -function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { - if (entry.unsubscribeConnectionListener !== null) { - return; - } - - entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { - if (attachThreadDetailSubscription(entry)) { - entry.lastAccessedAt = Date.now(); - } - }); - attachThreadDetailSubscription(entry); -} - -function disposeThreadDetailSubscriptionByKey(key: string): boolean { - const entry = threadDetailSubscriptions.get(key); - if (!entry) { - return false; - } - - clearThreadDetailSubscriptionEviction(entry); - entry.unsubscribeConnectionListener?.(); - entry.unsubscribeConnectionListener = null; - threadDetailSubscriptions.delete(key); - entry.unsubscribe(); - entry.unsubscribe = NOOP; - return true; -} - -function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId !== environmentId) { - continue; - } - entry.unsubscribe(); - entry.unsubscribe = NOOP; - watchThreadDetailSubscriptionConnection(entry); - } -} - -function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - attachThreadDetailSubscription(entry); - } - } -} - -function reconcileThreadDetailSubscriptionsForEnvironment( - environmentId: EnvironmentId, - threadIds: ReadonlyArray, -): void { - const activeThreadIds = new Set(threadIds); - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - entry.evictionTimeoutId = setTimeout(() => { - const currentEntry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - if (!currentEntry) { - return; - } - - currentEntry.evictionTimeoutId = null; - if (!shouldEvictThreadDetailSubscription(currentEntry)) { - return; - } - disposeThreadDetailSubscriptionByKey( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); -} - -function evictIdleThreadDetailSubscriptionsToCapacity(): void { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - - const idleEntries = [...threadDetailSubscriptions.entries()] - .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) - .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); - - for (const [key] of idleEntries) { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - disposeThreadDetailSubscriptionByKey(key); - } -} - -function reconcileThreadDetailSubscriptionEvictionState( - entry: ThreadDetailSubscriptionEntry, -): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - scheduleThreadDetailSubscriptionEviction(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForThread( - environmentId: EnvironmentId, - threadId: ThreadId, -): void { - const entry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(environmentId, threadId), - ); - if (!entry) { - return; - } - - reconcileThreadDetailSubscriptionEvictionState(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForEnvironment( - environmentId: EnvironmentId, -): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - reconcileThreadDetailSubscriptionEvictionState(entry); - } - } - evictIdleThreadDetailSubscriptionsToCapacity(); -} - -export function retainThreadDetailSubscription( - environmentId: EnvironmentId, - threadId: ThreadId, -): () => void { - const key = getThreadDetailSubscriptionKey(environmentId, threadId); - const existing = threadDetailSubscriptions.get(key); - if (existing) { - clearThreadDetailSubscriptionEviction(existing); - existing.refCount += 1; - existing.lastAccessedAt = Date.now(); - if (!attachThreadDetailSubscription(existing)) { - watchThreadDetailSubscriptionConnection(existing); - } - let released = false; - return () => { - if (released) { - return; - } - released = true; - existing.refCount = Math.max(0, existing.refCount - 1); - existing.lastAccessedAt = Date.now(); - if (existing.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(existing); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; - } - - const entry: ThreadDetailSubscriptionEntry = { - environmentId, - threadId, - unsubscribe: NOOP, - unsubscribeConnectionListener: null, - refCount: 1, - lastAccessedAt: Date.now(), - evictionTimeoutId: null, - }; - threadDetailSubscriptions.set(key, entry); - if (!attachThreadDetailSubscription(entry)) { - watchThreadDetailSubscriptionConnection(entry); - } - evictIdleThreadDetailSubscriptionsToCapacity(); - - let released = false; - return () => { - if (released) { - return; - } - released = true; - entry.refCount = Math.max(0, entry.refCount - 1); - entry.lastAccessedAt = Date.now(); - if (entry.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(entry); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; -} - -function emitEnvironmentConnectionRegistryChange() { - for (const listener of environmentConnectionListeners) { - listener(); - } -} - -function emitProviderInvalidation() { - for (const listener of providerInvalidationListeners) { - listener(); - } -} - -function getRuntimeErrorFields(error: unknown) { - return { - lastError: error instanceof Error ? error.message : String(error), - lastErrorAt: new Date().toISOString(), - } as const; -} - -function isoNow(): string { - return new Date().toISOString(); -} - -function readSshHttpErrorStatus(error: unknown): number | null { - if (!(error instanceof Error)) { - return null; - } - - const match = SSH_HTTP_STATUS_RE.exec(error.message); - if (!match) { - return null; - } - - const parsed = Number.parseInt(match[1] ?? "", 10); - return Number.isInteger(parsed) ? parsed : null; -} - -function isSshHttpAuthError(error: unknown, status: number): boolean { - return readSshHttpErrorStatus(error) === status; -} - -function isDesktopSshTargetEqual( - left: DesktopSshEnvironmentTarget | undefined, - right: DesktopSshEnvironmentTarget | undefined, -): boolean { - if (!left || !right) { - return false; - } - - return ( - left.alias === right.alias && - left.hostname === right.hostname && - left.username === right.username && - left.port === right.port - ); -} - -function findSavedEnvironmentRecordByDesktopSshTarget( - target: DesktopSshEnvironmentTarget | undefined, -): SavedEnvironmentRecord | null { - if (!target) { - return null; - } - - return ( - listSavedEnvironmentRecords().find((record) => - isDesktopSshTargetEqual(record.desktopSsh, target), - ) ?? null - ); -} - -function buildSavedEnvironmentRegistryById( - records: ReadonlyArray, -): Record { - return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< - EnvironmentId, - SavedEnvironmentRecord - >; -} - -type SavedEnvironmentRegistrySnapshot = ReadonlyMap; - -function snapshotSavedEnvironmentRegistry( - environmentIds: ReadonlyArray, -): SavedEnvironmentRegistrySnapshot { - return new Map( - environmentIds.map((environmentId) => [ - environmentId, - getSavedEnvironmentRecord(environmentId) ?? null, - ]), - ); -} - -async function persistSavedEnvironmentRegistryRollback( - snapshot: SavedEnvironmentRegistrySnapshot, -): Promise { - const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); - for (const [environmentId, record] of snapshot) { - if (record) { - byId[environmentId] = record; - continue; - } - delete byId[environmentId]; - } - const records = Object.values(byId); - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); - useSavedEnvironmentRegistryStore.setState({ - byId, - }); -} - -async function resolveDesktopSshEnvironmentBootstrap( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, -): Promise { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - - return await desktopBridge.ensureSshEnvironment(target, options); -} - -function getDesktopSshBridge() { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - return desktopBridge; -} - -async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { - return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); -} - -async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { - return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); -} - -function readIssuedBearerScopes(scope: string): ReadonlyArray { - return decodeIssuedBearerScopes(scope.split(" ")); -} - -async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { - return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); -} - -async function resolveDesktopSshWebSocketConnectionUrl( - wsBaseUrl: string, - httpBaseUrl: string, - bearerToken: string, -) { - const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); - const url = new URL(wsBaseUrl, window.location.origin); - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -} - -async function prepareSavedEnvironmentRecordForConnection( - record: SavedEnvironmentRecord, - options?: { readonly issuePairingToken?: boolean }, -): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly pairingToken: string | null; - readonly remotePort: number | null; - readonly remoteServerKind: "external" | "managed" | null; -}> { - if (!record.desktopSsh) { - return { - record, - pairingToken: null, - remotePort: null, - remoteServerKind: null, - }; - } - - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); - const nextRecord: SavedEnvironmentRecord = { - ...record, - httpBaseUrl: bootstrap.httpBaseUrl, - wsBaseUrl: bootstrap.wsBaseUrl, - desktopSsh: bootstrap.target, - }; - - if ( - nextRecord.httpBaseUrl !== record.httpBaseUrl || - nextRecord.wsBaseUrl !== record.wsBaseUrl || - !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) - ) { - await persistSavedEnvironmentRecord(nextRecord); - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - } - - return { - record: nextRecord, - pairingToken: bootstrap.pairingToken, - remotePort: bootstrap.remotePort ?? null, - remoteServerKind: bootstrap.remoteServerKind ?? null, - }; -} - -async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly bearerToken: string; - readonly scopes: ReadonlyArray | null; -}> { - const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); - const prepared = await prepareSavedEnvironmentRecordForConnection(record, { - issuePairingToken: true, - }); - if (!prepared.pairingToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - const bearerSession = await bootstrapDesktopSshBearerSession( - prepared.record.httpBaseUrl, - prepared.pairingToken, - ).catch(async (error) => { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - const detail = [ - `local ${prepared.record.httpBaseUrl}`, - `remote port ${prepared.remotePort ?? "unknown"}`, - prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - prepared.record.environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - - return { - record: prepared.record, - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }; -} - -function setRuntimeConnecting(environmentId: EnvironmentId) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connecting", - lastError: null, - lastErrorAt: null, - }); -} - -function setRuntimeConnected(environmentId: EnvironmentId) { - const connectedAt = isoNow(); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - authState: "authenticated", - connectedAt, - disconnectedAt: null, - lastError: null, - lastErrorAt: null, - }); - useSavedEnvironmentRegistryStore.getState().markConnected(environmentId, connectedAt); -} - -function setRuntimeDisconnected(environmentId: EnvironmentId, reason?: string | null) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "disconnected", - disconnectedAt: isoNow(), - ...(reason && reason.trim().length > 0 - ? { - lastError: reason, - lastErrorAt: isoNow(), - } - : {}), - }); -} - -function setRuntimeError(environmentId: EnvironmentId, error: unknown) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - ...getRuntimeErrorFields(error), - }); -} - -function coalesceOrchestrationUiEvents( - events: ReadonlyArray, -): OrchestrationEvent[] { - if (events.length < 2) { - return [...events]; - } - - const coalesced: OrchestrationEvent[] = []; - for (const event of events) { - const previous = coalesced.at(-1); - if ( - previous?.type === "thread.message-sent" && - event.type === "thread.message-sent" && - previous.payload.threadId === event.payload.threadId && - previous.payload.messageId === event.payload.messageId - ) { - coalesced[coalesced.length - 1] = { - ...event, - payload: { - ...event.payload, - attachments: event.payload.attachments ?? previous.payload.attachments, - createdAt: previous.payload.createdAt, - text: - !event.payload.streaming && event.payload.text.length > 0 - ? event.payload.text - : previous.payload.text + event.payload.text, - }, - }; - continue; - } - - coalesced.push(event); - } - - return coalesced; -} - -function syncProjectUiFromStore() { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); -} - -function syncThreadUiFromStore() { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - markPromotedDraftThreadsByRef( - threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), - ); -} - -function reconcileSnapshotDerivedState() { - syncProjectUiFromStore(); - syncThreadUiFromStore(); - - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - deletedAt: null, - archivedAt: thread.archivedAt, - })), - draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), - }); - useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys); -} - -function applyRecoveredEventBatch( - events: ReadonlyArray, - environmentId: EnvironmentId, -) { - if (events.length === 0) { - return; - } - - const batchEffects = deriveOrchestrationBatchEffects(events); - const uiEvents = coalesceOrchestrationUiEvents(events); - const needsProjectUiSync = events.some( - (event) => - event.type === "project.created" || - event.type === "project.meta-updated" || - event.type === "project.deleted", - ); - - if (batchEffects.needsProviderInvalidation) { - needsProviderInvalidation = true; - void activeService?.queryInvalidationThrottler.maybeExecute(); - } - - useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); - if (needsProjectUiSync) { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); - } - - const needsThreadUiSync = events.some( - (event) => event.type === "thread.created" || event.type === "thread.deleted", - ); - if (needsThreadUiSync) { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - } - - const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.promoteDraftThreadIds) { - markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); - } - for (const threadId of batchEffects.clearDeletedThreadIds) { - draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); - useUiStateStore - .getState() - .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); - } - for (const event of events) { - if (event.type === "project.deleted") { - draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); - } - } - for (const threadId of batchEffects.removeTerminalUiStateThreadIds) { - useTerminalUiStateStore - .getState() - .removeTerminalUiState(scopeThreadRef(environmentId, threadId)); - } - - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); -} - -export function applyEnvironmentThreadDetailEvent( - event: OrchestrationEvent, - environmentId: EnvironmentId, -) { - applyRecoveredEventBatch([event], environmentId); -} - -function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { - if ( - !shouldApplyProjectionEvent({ - current: readLastAppliedProjectionVersion(environmentId), - sequence: event.sequence, - }) - ) { - return; - } - - const threadId = - event.kind === "thread-upserted" - ? event.thread.id - : event.kind === "thread-removed" - ? event.threadId - : null; - const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; - const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; - - useStore.getState().applyShellEvent(event, environmentId); - markAppliedProjectionEvent(environmentId, event.sequence); - - switch (event.kind) { - case "project-upserted": - case "project-removed": - syncProjectUiFromStore(); - return; - case "thread-upserted": - syncThreadUiFromStore(); - if (!previousThread && threadRef) { - markPromotedDraftThreadByRef(threadRef); - } - if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); - evictIdleThreadDetailSubscriptionsToCapacity(); - return; - case "thread-removed": - if (threadRef) { - disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); - useComposerDraftStore.getState().clearDraftThread(threadRef); - useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - syncThreadUiFromStore(); - return; - } -} - -function createEnvironmentConnectionHandlers() { - return { - applyShellEvent, - syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Shell snapshots already have createShellSnapshotManager in - // @t3tools/client-runtime. Web currently projects snapshots straight into - // its denormalized Zustand store; future shell changes should migrate or - // bridge to the shared manager instead of growing this handler. - if ( - !shouldApplyProjectionSnapshot({ - current: readLastAppliedProjectionVersion(environmentId), - next: snapshot, - }) - ) { - return; - } - - useStore.getState().syncServerShellSnapshot(snapshot, environmentId); - markAppliedProjectionSnapshot(environmentId, snapshot); - reconcileThreadDetailSubscriptionsForEnvironment( - environmentId, - snapshot.threads.map((thread) => thread.id), - ); - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); - reconcileSnapshotDerivedState(); - }, - }; -} - -function createWsRpcClient(transport: WsTransport): WsRpcClient { - return createBaseWsRpcClient(transport, { - beforeReconnect: () => resetWsReconnectBackoff(), - }); -} - -function createPrimaryEnvironmentClient( - knownEnvironment: ReturnType, -) { - const wsBaseUrl = getKnownEnvironmentWsBaseUrl(knownEnvironment); - if (!wsBaseUrl) { - throw new Error( - `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, - ); - } - const connectionLabel = knownEnvironment?.label ?? null; - - return createWsRpcClient( - new WsTransport(wsBaseUrl, { - getConnectionLabel: () => connectionLabel, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null, - }), - ); -} - -function createSavedEnvironmentClient( - environmentId: EnvironmentId, - credentialRef: { current: SavedEnvironmentCredential }, - relayTraceHeadersRef: { current: Headers.Headers | null }, -): WsRpcClient { - useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); - - return createWsRpcClient( - new WsTransport( - async () => { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - const credential = credentialRef.current; - if (record.desktopSsh) { - if (credential.method !== "bearer") { - throw new Error("SSH environments require bearer credentials."); - } - return await resolveDesktopSshWebSocketConnectionUrl( - record.wsBaseUrl, - record.httpBaseUrl, - credential.token, - ); - } - if (credential.method === "dpop") { - try { - const relayTraceHeaders = relayTraceHeadersRef.current; - relayTraceHeadersRef.current = null; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders), - ); - } catch (error) { - if (!isEnvironmentAuthInvalidError(error)) { - throw error; - } - const renewed = await renewManagedRelayCredential(record); - if (!renewed || renewed.credential.method !== "dpop") { - throw error; - } - const renewedCredential = renewed.credential; - credentialRef.current = renewedCredential; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl( - renewed.record, - renewedCredential, - renewed.relayTraceHeaders, - ), - ); - } - } - return await webRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ); - }, - { - getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - )?.hint ?? null, - onAttempt: () => { - setRuntimeConnecting(environmentId); - }, - onOpen: () => { - setRuntimeConnected(environmentId); - }, - onError: (message: string) => { - const mismatch = resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - lastError: appendVersionMismatchHint(message, mismatch), - lastErrorAt: isoNow(), - }); - }, - onClose: (details: { readonly code: number; readonly reason: string }) => { - setRuntimeDisconnected( - environmentId, - appendVersionMismatchHint( - details.reason, - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ), - ), - ); - }, - }, - ), - ); -} - -async function refreshSavedEnvironmentMetadata( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, - client: WsRpcClient, - scopeHint?: ReadonlyArray | null, - configHint?: ServerConfig | null, -): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - - const [serverConfig, sessionState] = await Promise.all([ - configHint ? Promise.resolve(configHint) : client.server.getConfig(), - record.desktopSsh - ? credential.method === "bearer" - ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) - : Promise.reject(new Error("SSH environments require bearer credentials.")) - : credential.method === "dpop" - ? webRuntime.runPromise( - createManagedRelayDpopProof({ - method: "GET", - url: new URL("/api/auth/session", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - fetchRemoteDpopSessionState({ - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ), - ) - : webRuntime.runPromise( - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ), - ]); - - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: sessionState.authenticated ? "authenticated" : "requires-auth", - descriptor: serverConfig.environment, - serverConfig, - scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, - }); - useSavedEnvironmentRegistryStore - .getState() - .rename(record.environmentId, serverConfig.environment.label); -} - -const resolveManagedRelayWebSocketUrl = Effect.fn( - "web.environment.resolveManagedRelayWebSocketUrl", -)(function* ( - record: SavedEnvironmentRecord, - credential: Extract, - traceHeaders: Headers.Headers | null, -) { - const request = createManagedRelayDpopProof({ - method: "POST", - url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ); - const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none(); - return yield* ( - Option.isSome(parent) - ? request.pipe(Effect.withParentSpan(parent.value)) - : request.pipe( - Effect.withSpan("relay.environment.reconnect", { - root: true, - attributes: { "relay.environment_id": record.environmentId }, - }), - ) - ).pipe(withRelayClientTracing); -}); - -async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly credential: SavedEnvironmentCredential; - readonly relayTraceHeaders: Headers.Headers; -} | null> { - if (!record.relayManaged) { - return null; - } - const clerkToken = await readManagedRelayClerkToken(); - if (!clerkToken) { - return null; - } - const connected = await webRuntime.runPromise( - connectManagedCloudEnvironment({ - clerkToken, - relayUrl: record.relayManaged.relayUrl, - environment: { - environmentId: record.environmentId, - label: record.label, - linkedAt: record.createdAt, - endpoint: { - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - providerKind: "cloudflare_tunnel", - }, - }, - }), - ); - const nextRecord: SavedEnvironmentRecord = { - ...record, - label: connected.label, - httpBaseUrl: connected.httpBaseUrl, - wsBaseUrl: connected.wsBaseUrl, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: connected.accessToken, - }; - await persistSavedEnvironmentRecord(nextRecord); - if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { - throw new Error("Unable to persist refreshed managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders }; -} - -function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { - const existing = environmentConnections.get(connection.environmentId); - if (existing && existing !== connection) { - throw new Error(`Environment ${connection.environmentId} already has an active connection.`); - } - environmentConnections.set(connection.environmentId, connection); - terminalMetadataSubscriptions.get(connection.environmentId)?.(); - terminalMetadataSubscriptions.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client: connection.client, - }), - ); - portDiscoverySubscriptions.get(connection.environmentId)?.(); - portDiscoverySubscriptions.set( - connection.environmentId, - subscribePortDiscovery({ - environmentId: connection.environmentId, - previewApi: connection.client.preview, - }), - ); - attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); - emitEnvironmentConnectionRegistryChange(); - return connection; -} - -async function removeConnection(environmentId: EnvironmentId): Promise { - const connection = environmentConnections.get(environmentId); - if (!connection) { - return false; - } - - lastAppliedProjectionVersionByEnvironment.delete(environmentId); - environmentConnections.delete(environmentId); - terminalMetadataSubscriptions.get(environmentId)?.(); - terminalMetadataSubscriptions.delete(environmentId); - portDiscoverySubscriptions.get(environmentId)?.(); - portDiscoverySubscriptions.delete(environmentId); - usePortDiscoveryStore.getState().clearEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - emitEnvironmentConnectionRegistryChange(); - detachThreadDetailSubscriptionsForEnvironment(environmentId); - await connection.dispose(); - return true; -} - -function createPrimaryEnvironmentConnection(): EnvironmentConnection { - const knownEnvironment = getPrimaryKnownEnvironment(); - if (!knownEnvironment?.environmentId) { - throw new Error("Unable to resolve the primary environment."); - } - - const existing = environmentConnections.get(knownEnvironment.environmentId); - if (existing) { - return existing; - } - - return registerConnection( - createEnvironmentConnection({ - kind: "primary", - knownEnvironment, - client: createPrimaryEnvironmentClient(knownEnvironment), - ...createEnvironmentConnectionHandlers(), - }), - ); -} - -function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { - return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; -} - -async function ensureSavedEnvironmentConnection( - record: SavedEnvironmentRecord, - options?: { - readonly client?: WsRpcClient; - readonly bearerToken?: string; - readonly credential?: SavedEnvironmentCredential; - readonly scopes?: ReadonlyArray | null; - readonly serverConfig?: ServerConfig | null; - readonly allowManagedRenewal?: boolean; - readonly relayTraceHeaders?: Headers.Headers; - }, -): Promise { - const existing = environmentConnections.get(record.environmentId); - if (existing) { - return existing; - } - - const pending = pendingSavedEnvironmentConnections.get(record.environmentId); - if (pending) { - return pending.promise; - } - - const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId); - const pendingEntry: PendingSavedEnvironmentConnection = { - isCurrent: attempt.isCurrent, - promise: Promise.resolve().then(async () => { - let activeRecord = record; - let scopeHint = options?.scopes ?? null; - let credential = - options?.credential ?? - (options?.bearerToken - ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) - : await readSavedEnvironmentCredential(record.environmentId)); - if (!credential) { - if (record.desktopSsh) { - const issued = await issueDesktopSshBearerSession(record); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - } else { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: "requires-auth", - scopes: null, - connectionState: "disconnected", - lastError: "Saved environment is missing its saved credential. Pair it again.", - lastErrorAt: isoNow(), - }); - throw new Error("Saved environment is missing its saved credential."); - } - } else { - const prepared = await prepareSavedEnvironmentRecordForConnection(record); - activeRecord = prepared.record; - } - - const activeCredential = { current: credential }; - const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null }; - const client = - options?.client ?? - createSavedEnvironmentClient( - activeRecord.environmentId, - activeCredential, - relayTraceHeaders, - ); - const initialConfigSnapshot = createDeferredPromise(); - const knownEnvironment = createKnownEnvironment({ - id: activeRecord.environmentId, - label: activeRecord.label, - source: "manual", - target: { - httpBaseUrl: activeRecord.httpBaseUrl, - wsBaseUrl: activeRecord.wsBaseUrl, - }, - }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...knownEnvironment, - environmentId: activeRecord.environmentId, - }, - client, - refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - ); - }, - onConfigSnapshot: (config) => { - initialConfigSnapshot.resolve(config); - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: config.environment, - serverConfig: config, - }); - }, - onWelcome: (payload) => { - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: payload.environment, - }); - }, - ...createEnvironmentConnectionHandlers(), - }); - - try { - try { - const initialServerConfig = - options?.serverConfig ?? - (await waitForConfigSnapshot( - initialConfigSnapshot.promise, - INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS, - )); - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - scopeHint, - initialServerConfig, - ); - } catch (error) { - const isAuthError = activeRecord.desktopSsh - ? isSshHttpAuthError(error, 401) - : isEnvironmentAuthInvalidError(error); - if (!isAuthError) { - throw error; - } - if (!activeRecord.desktopSsh) { - if ( - activeCredential.current.method === "dpop" && - options?.allowManagedRenewal !== false - ) { - const renewed = await renewManagedRelayCredential(activeRecord); - if (renewed) { - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(renewed.record, { - credential: renewed.credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - allowManagedRenewal: false, - relayTraceHeaders: renewed.relayTraceHeaders, - }); - } - } - await removeSavedEnvironmentBearerToken(activeRecord.environmentId); - throw new Error( - activeCredential.current.method === "dpop" - ? "Managed tunnel credential expired. Connect it again from T3 Connect." - : "Saved environment credential expired. Pair it again.", - { - cause: error, - }, - ); - } - - const issued = await issueDesktopSshBearerSession(activeRecord); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(activeRecord, { - credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - }); - } - if ( - !pendingEntry.isCurrent() || - pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry - ) { - await connection.dispose().catch(() => undefined); - throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId); - } - registerConnection(connection); - return connection; - } catch (error) { - if (error instanceof EnvironmentConnectionAttemptCancelledError) { - throw error; - } - setRuntimeError(activeRecord.environmentId, error); - const removed = await removeConnection(activeRecord.environmentId).catch(() => false); - if (!removed) { - await connection.dispose().catch(() => undefined); - } - throw error; - } - }), - }; - - pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry); - return await pendingEntry.promise.finally(() => { - if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { - pendingSavedEnvironmentConnections.delete(record.environmentId); - savedEnvironmentConnectionAttempts.cancel(record.environmentId); - } - }); -} - -async function syncSavedEnvironmentConnections( - records: ReadonlyArray, -): Promise { - const expectedEnvironmentIds = new Set(records.map((record) => record.environmentId)); - const staleEnvironmentIds: EnvironmentId[] = []; - for (const connection of environmentConnections.values()) { - if (connection.kind !== "saved") continue; - if (expectedEnvironmentIds.has(connection.environmentId)) continue; - staleEnvironmentIds.push(connection.environmentId); - } - - await Promise.all( - staleEnvironmentIds.map((environmentId) => disconnectSavedEnvironment(environmentId)), - ); - await Promise.all( - records.map((record) => ensureSavedEnvironmentConnection(record).catch(() => undefined)), - ); -} - -function stopActiveService() { - activeService?.stop(); - activeService = null; -} - -function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { - const now = Date.now(); - if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { - return; - } - - for (const connection of environmentConnections.values()) { - if (connection.client.isHeartbeatFresh()) { - continue; - } - lastBrowserResumeReconnectAt = now; - void connection.reconnect().catch((error) => { - console.warn("Environment reconnect after browser resume failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - } -} - -function subscribeBrowserResumeReconnects(): () => void { - if (typeof document === "undefined" || typeof window === "undefined") { - return NOOP; - } - - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") { - lastBrowserHiddenAt = Date.now(); - return; - } - if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); - } - }; - - const handlePageShow = (event: PageTransitionEvent) => { - if (event.persisted || lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("pageshow", handlePageShow); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("pageshow", handlePageShow); - }; -} - -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} - -export function subscribeProviderInvalidations(listener: () => void): () => void { - providerInvalidationListeners.add(listener); - return () => { - providerInvalidationListeners.delete(listener); - }; -} - -export function listEnvironmentConnections(): ReadonlyArray { - return [...environmentConnections.values()]; -} - -export function readEnvironmentConnection( - environmentId: EnvironmentId, -): EnvironmentConnection | null { - return environmentConnections.get(environmentId) ?? null; -} - -export function requireEnvironmentConnection(environmentId: EnvironmentId): EnvironmentConnection { - const connection = readEnvironmentConnection(environmentId); - if (!connection) { - throw new Error(`No websocket client registered for environment ${environmentId}.`); - } - return connection; -} - -export function getPrimaryEnvironmentConnection(): EnvironmentConnection { - return createPrimaryEnvironmentConnection(); -} - -export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); - if (pendingConnection) { - savedEnvironmentConnectionAttempts.cancel(environmentId); - pendingSavedEnvironmentConnections.delete(environmentId); - } - const connection = environmentConnections.get(environmentId); - - if (connection?.kind === "saved") { - await removeConnection(environmentId).catch(() => false); - } - setRuntimeDisconnected(environmentId); - - if (record?.desktopSsh && typeof window !== "undefined") { - await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); - await removeSavedEnvironmentBearerToken(environmentId); - } -} - -export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error("Saved environment not found."); - } - - const connection = environmentConnections.get(environmentId); - if (!connection) { - setRuntimeConnecting(environmentId); - try { - await ensureSavedEnvironmentConnection(record); - return; - } catch (error) { - if (isSavedEnvironmentConnectionCancelledError(error)) { - return; - } - setRuntimeError(environmentId, error); - throw error; - } - } - - setRuntimeConnecting(environmentId); - try { - if (record.desktopSsh) { - await prepareSavedEnvironmentRecordForConnection(record); - } - await connection.reconnect(); - } catch (error) { - if (record.desktopSsh) { - try { - const issued = await issueDesktopSshBearerSession( - getSavedEnvironmentRecord(environmentId) ?? record, - ); - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(issued.record, { - bearerToken: issued.bearerToken, - scopes: issued.scopes, - }); - return; - } catch (recoveryError) { - if (isSavedEnvironmentConnectionCancelledError(recoveryError)) { - return; - } - setRuntimeError(environmentId, recoveryError); - throw recoveryError; - } - } - setRuntimeError(environmentId, error); - throw error; - } -} - -export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise { - await disconnectSavedEnvironment(environmentId); - disposeThreadDetailSubscriptionsForEnvironment(environmentId); - useSavedEnvironmentRegistryStore.getState().remove(environmentId); - useSavedEnvironmentRuntimeStore.getState().clear(environmentId); - useStore.getState().removeEnvironmentState(environmentId); - await removeSavedEnvironmentBearerToken(environmentId); -} - -export async function addSavedEnvironment(input: { - readonly label: string; - readonly pairingUrl?: string; - readonly host?: string; - readonly pairingCode?: string; - readonly desktopSsh?: DesktopSshEnvironmentTarget; -}): Promise { - const resolvedTarget = resolveRemotePairingTarget({ - ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), - ...(input.host !== undefined ? { host: input.host } : {}), - ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), - }); - const descriptor = input.desktopSsh - ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) - : await webRuntime.runPromise( - fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - }), - ); - const environmentId = descriptor.environmentId; - const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); - const existingRecord = - getSavedEnvironmentRecord(environmentId) ?? - findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); - const staleDesktopSshRecord = - existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; - - const bearerSession = input.desktopSsh - ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) - : await webRuntime.runPromise( - bootstrapRemoteBearerSession({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - credential: resolvedTarget.credential, - }), - ); - - const record: SavedEnvironmentRecord = { - environmentId, - label: input.label.trim() || existingRecord?.label || descriptor.label, - wsBaseUrl: resolvedTarget.wsBaseUrl, - httpBaseUrl: resolvedTarget.httpBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - ...((input.desktopSsh ?? existingRecord?.desktopSsh) - ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } - : {}), - }; - - await persistSavedEnvironmentRecord(record); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - if (staleDesktopSshRecord) { - await removeSavedEnvironment(staleDesktopSshRecord.environmentId); - } - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }); - return record; -} - -export async function addManagedRelayEnvironment(input: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -}): Promise { - const existingRecord = getSavedEnvironmentRecord(input.environmentId); - const record: SavedEnvironmentRecord = { - environmentId: input.environmentId, - label: input.label.trim() || existingRecord?.label || "Managed environment", - httpBaseUrl: input.httpBaseUrl, - wsBaseUrl: input.wsBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - relayManaged: { relayUrl: input.relayUrl }, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: input.accessToken, - }; - - await persistSavedEnvironmentRecord(record); - if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { - throw new Error("Unable to persist managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - await removeConnection(record.environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - credential, - relayTraceHeaders: input.relayTraceHeaders, - }); - return record; -} - -export async function connectDesktopSshEnvironment( - target: DesktopSshEnvironmentTarget, - options?: { label?: string }, -): Promise { - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { - issuePairingToken: true, - }); - if (!bootstrap.pairingToken) { - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - return await addSavedEnvironment({ - label: options?.label?.trim() || bootstrap.target.alias, - host: bootstrap.httpBaseUrl, - pairingCode: bootstrap.pairingToken, - desktopSsh: bootstrap.target, - }).catch((error) => { - const detail = [ - `local ${bootstrap.httpBaseUrl}`, - `remote port ${bootstrap.remotePort ?? "unknown"}`, - bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); -} - -export async function ensureEnvironmentConnectionBootstrapped( - environmentId: EnvironmentId, -): Promise { - await environmentConnections.get(environmentId)?.ensureBootstrapped(); -} - -export function startEnvironmentConnectionService(queryClient: QueryClient): () => void { - if (activeService?.queryClient === queryClient) { - activeService.refCount += 1; - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; - } - - stopActiveService(); - needsProviderInvalidation = false; - const queryInvalidationThrottler = new Throttler( - () => { - if (!needsProviderInvalidation) { - return; - } - needsProviderInvalidation = false; - emitProviderInvalidation(); - }, - { - wait: 100, - leading: false, - trailing: true, - }, - ); - const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler(); - - maybeCreatePrimaryEnvironmentConnection(); - - const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { - if (!hasSavedEnvironmentRegistryHydrated()) { - return; - } - void requestSavedEnvironmentSync(); - }); - - void waitForSavedEnvironmentRegistryHydration() - .then(() => requestSavedEnvironmentSync()) - .catch(() => undefined); - - const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); - - activeService = { - queryClient, - queryInvalidationThrottler, - refCount: 1, - stop: () => { - unsubscribeSavedEnvironments(); - unsubscribeBrowserResumeReconnects(); - queryInvalidationThrottler.cancel(); - }, - }; - - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; -} - -export async function resetEnvironmentServiceForTests(): Promise { - stopActiveService(); - lastBrowserHiddenAt = null; - lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - lastAppliedProjectionVersionByEnvironment.clear(); - pendingSavedEnvironmentConnections.clear(); - savedEnvironmentConnectionAttempts.clear(); - for (const key of Array.from(threadDetailSubscriptions.keys())) { - disposeThreadDetailSubscriptionByKey(key); - } - for (const unsubscribe of terminalMetadataSubscriptions.values()) { - unsubscribe(); - } - terminalMetadataSubscriptions.clear(); - for (const unsubscribe of portDiscoverySubscriptions.values()) { - unsubscribe(); - } - portDiscoverySubscriptions.clear(); - usePortDiscoveryStore.getState().reset(); - terminalSessionManager.reset(); - await Promise.all( - [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), - ); -} diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts index 2ccdb66016d..b4be13716ea 100644 --- a/apps/web/src/historyBootstrap.test.ts +++ b/apps/web/src/historyBootstrap.test.ts @@ -14,6 +14,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "hello", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -21,6 +23,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "world", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, ], @@ -45,6 +49,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "first question with details", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -52,6 +58,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "first answer with details", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, { @@ -59,6 +67,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "second question with details", createdAt: "2026-02-09T00:00:02.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:02.000Z", streaming: false, }, ], @@ -82,6 +92,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "old context", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], @@ -112,6 +124,8 @@ describe("buildBootstrapInput", () => { }, ], createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], diff --git a/apps/web/src/hooks/useCommitOnBlur.ts b/apps/web/src/hooks/useCommitOnBlur.ts index 43244762aa1..d1154fbb265 100644 --- a/apps/web/src/hooks/useCommitOnBlur.ts +++ b/apps/web/src/hooks/useCommitOnBlur.ts @@ -1,4 +1,4 @@ -import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { type ChangeEvent, type KeyboardEvent, useState } from "react"; /** * Buffer text input locally so keystrokes don't cause a settings-wide @@ -16,27 +16,21 @@ import { type ChangeEvent, type KeyboardEvent, useEffect, useRef, useState } fro * */ export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { - const [draft, setDraft] = useState(value); - const focusedRef = useRef(false); - - useEffect(() => { - if (!focusedRef.current) { - setDraft(value); - } - }, [value]); + const [draft, setDraft] = useState(null); return { - value: draft, + value: draft ?? value, onChange: (event: ChangeEvent) => { setDraft(event.target.value); }, onFocus: () => { - focusedRef.current = true; + setDraft(value); }, onBlur: () => { - focusedRef.current = false; - if (draft !== value) { - onCommit(draft); + const next = draft ?? value; + setDraft(null); + if (next !== value) { + onCommit(next); } }, onKeyDown: (event: KeyboardEvent) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e440497ba42..0b802dd8736 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,9 +1,13 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { + scopedProjectKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { useShallow } from "zustand/react/shallow"; import { + markPromotedDraftThreadByRef, type DraftThreadEnvMode, type DraftThreadState, useComposerDraftStore, @@ -15,14 +19,13 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { selectProjectsAcrossEnvironments, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveThreadRouteTarget } from "../threadRoutes"; -import { useUiStateStore } from "../uiStateStore"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; -function useNewThreadState() { - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); +export function useNewThreadHandler() { + const projects = useProjects(); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -60,32 +63,47 @@ function useNewThreadState() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); + const storedDraftThreadRef = storedDraftThread + ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) + : null; + const reusableStoredDraftThread = + storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null + ? null + : storedDraftThread; + if (storedDraftThreadRef && reusableStoredDraftThread === null) { + markPromotedDraftThreadByRef(storedDraftThreadRef); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) : getDraftSession(currentRouteTarget.draftId) : null; - if (storedDraftThread) { + if (reusableStoredDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.draftId, { + setDraftThreadContext(reusableStoredDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { - threadId: storedDraftThread.threadId, - }); + setLogicalProjectDraftThreadId( + logicalProjectKey, + projectRef, + reusableStoredDraftThread.draftId, + { + threadId: reusableStoredDraftThread.threadId, + }, + ); if ( currentRouteTarget?.kind === "draft" && - currentRouteTarget.draftId === storedDraftThread.draftId + currentRouteTarget.draftId === reusableStoredDraftThread.draftId ) { return; } await router.navigate({ to: "/draft/$draftId", - params: { draftId: storedDraftThread.draftId }, + params: { draftId: reusableStoredDraftThread.draftId }, }); })(); } @@ -139,14 +157,6 @@ function useNewThreadState() { ); } -export function useNewThreadHandler() { - const handleNewThread = useNewThreadState(); - - return { - handleNewThread, - }; -} - export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); const routeTarget = useParams({ @@ -154,9 +164,7 @@ export function useHandleNewThread() { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); + const activeThread = useThread(routeThreadRef); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const activeDraftThread = useComposerDraftStore(() => routeTarget @@ -165,15 +173,19 @@ export function useHandleNewThread() { : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) : null, ); - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projects = useProjects(); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], }); }, [projectOrder, projects]); - const handleNewThread = useNewThreadState(); + const handleNewThread = useNewThreadHandler(); return { activeDraftThread, diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 93d26f66329..50e81dbc0b8 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; const isomorphicLocalStorage: Storage = typeof window !== "undefined" @@ -63,85 +63,69 @@ export function useLocalStorage( initialValue: T, schema: Schema.Codec, ): [T, (value: T | ((val: T) => T)) => void] { - // Get the initial value from localStorage or use the provided initialValue - const [storedValue, setStoredValue] = useState(() => { + const getSnapshot = useCallback(() => { try { - const item = getLocalStorageItem(key, schema); - return item ?? initialValue; + return isomorphicLocalStorage.getItem(key); + } catch (error) { + console.error("[LOCALSTORAGE] Error:", error); + return null; + } + }, [key]); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === key) { + onStoreChange(); + } + }; + const handleLocalChange = (event: CustomEvent) => { + if (event.detail.key === key) { + onStoreChange(); + } + }; + + window.addEventListener("storage", handleStorageChange); + window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + }; + }, + [key], + ); + + const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const storedValue = useMemo(() => { + if (serializedValue === null) { + return initialValue; + } + try { + return decode(schema, serializedValue); } catch (error) { console.error("[LOCALSTORAGE] Error:", error); return initialValue; } - }); + }, [initialValue, schema, serializedValue]); - // Return a wrapped version of useState's setter function that persists the new value to localStorage const setValue = useCallback( (value: T | ((val: T) => T)) => { try { - setStoredValue((prev) => { - const valueToStore = typeof value === "function" ? (value as (val: T) => T)(prev) : value; - if (valueToStore === null) { - removeLocalStorageItem(key); - } else { - setLocalStorageItem(key, valueToStore, schema); - } - // Dispatch event after state update completes to avoid nested state updates - queueMicrotask(() => dispatchLocalStorageChange(key)); - return valueToStore; - }); + const currentValue = getLocalStorageItem(key, schema) ?? initialValue; + const valueToStore = + typeof value === "function" ? (value as (val: T) => T)(currentValue) : value; + if (valueToStore === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, valueToStore, schema); + } + dispatchLocalStorageChange(key); } catch (error) { console.error("[LOCALSTORAGE] Error:", error); } }, - [key, schema], + [initialValue, key, schema], ); - const prevKeyRef = useRef(key); - - // Re-sync from localStorage when key changes - useEffect(() => { - if (prevKeyRef.current !== key) { - prevKeyRef.current = key; - try { - const newValue = getLocalStorageItem(key, schema); - setStoredValue(newValue ?? initialValue); - } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); - } - } - }, [key, initialValue, schema]); - - // Listen for storage events from other tabs AND custom events from the same tab - useEffect(() => { - const syncFromStorage = () => { - try { - const newValue = getLocalStorageItem(key, schema); - setStoredValue(newValue ?? initialValue); - } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); - } - }; - - const handleStorageChange = (event: StorageEvent) => { - if (event.key === key) { - syncFromStorage(); - } - }; - - const handleLocalChange = (event: CustomEvent) => { - if (event.detail.key === key) { - syncFromStorage(); - } - }; - - window.addEventListener("storage", handleStorageChange); - window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - - return () => { - window.removeEventListener("storage", handleStorageChange); - window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - }; - }, [key, initialValue, schema]); - return [storedValue, setValue]; } diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 3552c82d9dc..d3c7207c185 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -1,11 +1,5 @@ import * as Schema from "effect/Schema"; -import { - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; @@ -66,10 +60,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } }); - // Re-clamp if min/max change at runtime (e.g. window resize narrows max). - useEffect(() => { - setWidth((current) => clamp(current)); - }, [clamp]); + const clampedWidth = clamp(width); const dragStateRef = useRef<{ pointerId: number; @@ -114,13 +105,13 @@ export function useResizableWidth(options: UseResizableWidthOptions): { dragStateRef.current = { pointerId: event.pointerId, startX: event.clientX, - startWidth: width, - pending: width, + startWidth: clampedWidth, + pending: clampedWidth, rafId: null, target, }; }, - [width], + [clampedWidth], ); const onPointerMove = useCallback( @@ -170,7 +161,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { ); return { - width, + width: clampedWidth, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, }; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 005c8ad82fc..6759b227a13 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -10,18 +10,19 @@ * store. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, - DEFAULT_UNIFIED_SETTINGS, UnifiedSettings, } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; +import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; +import { usePrimaryEnvironment } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -30,6 +31,7 @@ const clientSettingsHydrationListeners = new Set<() => void>(); let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; +let clientSettingsHydrationGeneration = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -88,16 +90,22 @@ async function hydrateClientSettings(): Promise { return clientSettingsHydrationPromise; } + const hydrationGeneration = clientSettingsHydrationGeneration; const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + if (hydrationGeneration !== clientSettingsHydrationGeneration) { + return; + } if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); } finally { - setClientSettingsHydrated(true); + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrated(true); + } } })(); @@ -168,7 +176,7 @@ export function useClientSettingsHydrated(): boolean { } export function useSettings(selector?: (s: UnifiedSettings) => T): T { - const serverSettings = useServerSettings(); + const serverSettings = useAtomValue(primaryServerSettingsAtom); const clientSettings = useSyncExternalStore( subscribeClientSettings, getClientSettingsSnapshot, @@ -193,40 +201,49 @@ export function useSettings(selector?: (s: UnifiedSettings) * persisted via RPC. Client keys go through client persistence. */ export function useUpdateSettings() { - const updateSettings = useCallback((patch: Partial) => { - const { serverPatch, clientPatch } = splitPatch(patch); - - if (Object.keys(serverPatch).length > 0) { - const currentServerConfig = getServerConfig(); - if (currentServerConfig) { - applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); + const persistServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "server settings update", + ); + const primaryEnvironment = usePrimaryEnvironment(); + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + if (primaryEnvironment) { + void persistServerSettings({ + environmentId: primaryEnvironment.environmentId, + input: { patch: serverPatch }, + }); + } } - // Fire-and-forget RPC — push will reconcile on success - void ensureLocalApi().server.updateSettings(serverPatch); - } - - if (Object.keys(clientPatch).length > 0) { - persistClientSettings({ - ...getClientSettingsSnapshot(), - ...clientPatch, - }); - } - }, []); - const resetSettings = useCallback(() => { - updateSettings(DEFAULT_UNIFIED_SETTINGS); - }, [updateSettings]); + if (Object.keys(clientPatch).length > 0) { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...clientPatch, + }); + } + }, + [persistServerSettings, primaryEnvironment], + ); - return { - updateSettings, - resetSettings, - }; + return updateSettings; } export function __resetClientSettingsPersistenceForTests(): void { + clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } + +export function __setClientSettingsForTests(settings: ClientSettings): void { + clientSettingsHydrationGeneration += 1; + clientSettingsSnapshot = settings; + clientSettingsHydrated = true; + clientSettingsHydrationPromise = null; +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7325a96913d..f174ed8e6c6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,29 +1,54 @@ -import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; -import { invalidateSourceControlState } from "../lib/sourceControlActions"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; -import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; -import { - selectProjectByRef, - selectThreadByRef, - selectThreadsForEnvironment, - useStore, -} from "../store"; +import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; +import { useAtomCommand } from "../state/use-atom-command"; + +export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{ + readonly message: string; +}> {} export function useThreadActions() { + const closeTerminal = useAtomCommand(terminalEnvironment.close); + const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { + reportFailure: false, + }); + const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + reportFailure: false, + }); + const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { + reportFailure: false, + }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); + const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { + reportFailure: false, + }); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, + }); const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); @@ -32,7 +57,7 @@ export function useThreadActions() { ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); const router = useRouter(); - const { handleNewThread } = useNewThreadHandler(); + const handleNewThread = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in // its dependency array — handleNewThread is inherently unstable (depends on // the projects list) and would otherwise cascade new references into every @@ -41,8 +66,7 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const state = useStore.getState(); - const thread = selectThreadByRef(state, target); + const thread = readThreadShell(target); if (!thread) { return null; } @@ -58,65 +82,82 @@ export function useThreadActions() { const archiveThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); - if (!resolved) return; + if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { - throw new Error("Cannot archive a running thread."); + return AsyncResult.failure( + Cause.fail( + new ThreadArchiveBlockedError({ + message: "Cannot archive a running thread.", + }), + ), + ); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveCommand = api.orchestration.dispatchCommand({ - type: "thread.archive", - commandId: newCommandId(), - threadId: threadRef.threadId, + const archiveResult = await archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (archiveResult._tag === "Failure") { + return archiveResult; + } if (shouldNavigateToDraft) { - await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); + const navigationResult = await settlePromise(() => + handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; } - await archiveCommand; refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return archiveResult; }, - [getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; - await api.orchestration.dispatchCommand({ - type: "thread.unarchive", - commandId: newCommandId(), - threadId: target.threadId, - }); - refreshArchivedThreadsForEnvironment(target.environmentId); - }, []); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef) => { + const result = await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; + }, + [unarchiveThreadMutation], + ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: target.threadId, + const result = await deleteThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, }); - refreshArchivedThreadsForEnvironment(target.environmentId); - return; + if (result._tag === "Success") { + refreshArchivedThreadsForEnvironment(target.environmentId); + } + return result; } const { thread, threadRef } = resolved; - const state = useStore.getState(); - const threads = selectThreadsForEnvironment(state, threadRef.environmentId); - const threadProject = selectProjectByRef(state, { + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const threadProject = readProject({ environmentId: threadRef.environmentId, projectId: thread.projectId, }); @@ -140,37 +181,38 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); - const shouldDeleteWorktree = - canDeleteWorktree && - localApi && - (await localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), - )); - - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: threadRef.threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + let shouldDeleteWorktree = false; + if (canDeleteWorktree && localApi) { + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + ), + ); + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + shouldDeleteWorktree = confirmationResult.value; } - try { - await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed. + if (thread.session && thread.session.status !== "stopped") { + await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); } + await closeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, deleteHistory: true }, + }); + const deletedThreadIds = deletedIds ?? new Set(); const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToFallback = @@ -182,11 +224,13 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadRef.threadId, + const deleteResult = await deleteThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); + if (deleteResult._tag === "Failure") { + return deleteResult; + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -197,44 +241,71 @@ export function useThreadActions() { if (shouldNavigateToFallback) { if (fallbackThreadId) { - const fallbackThread = selectThreadByRef( - useStore.getState(), + const fallbackThread = readThreadShell( scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { - await router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams( - scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), - ), - replace: true, - }); + const navigationResult = await settlePromise(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } else { - await router.navigate({ to: "/", replace: true }); + const navigationResult = await settlePromise(() => + router.navigate({ to: "/", replace: true }), + ); + if (navigationResult._tag === "Failure") { + return navigationResult; + } } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { - return; + return deleteResult; } - try { - await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ - cwd: threadProject.cwd, + const removeResult = await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true, - }); - await invalidateSourceControlState({ - environmentId: threadRef.environmentId, - }); - } catch (error) { + }, + }); + const refreshResult = + removeResult._tag === "Success" + ? await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }) + : null; + const cleanupFailure = + removeResult._tag === "Failure" + ? removeResult + : refreshResult?._tag === "Failure" + ? refreshResult + : null; + if (cleanupFailure) { + const error = squashAtomCommandFailure(cleanupFailure); const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId: threadRef.threadId, - projectCwd: threadProject.cwd, + projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, error, }); @@ -245,48 +316,61 @@ export function useThreadActions() { description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); + return cleanupFailure; } + return deleteResult; }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, + closeTerminal, + deleteThreadMutation, getCurrentRouteThreadRef, + refreshVcsStatus, + removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, + stopThreadSession, ], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { const title = resolved?.thread.title ?? "this thread"; - const confirmed = await localApi.dialogs.confirm( - [ - `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm( + [ + `Delete thread "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), ); - if (!confirmed) { - return; + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + if (!confirmationResult.value) { + return AsyncResult.success(undefined); } } - await deleteThread(target); + return deleteThread(target); }, [confirmThreadDelete, deleteThread, resolveThreadTarget], ); - return { - archiveThread, - unarchiveThread, - deleteThread, - confirmAndDeleteThread, - }; + return useMemo( + () => ({ + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }), + [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + ); } diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index 2bf72c96cca..f51acc15cc0 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { inferCheckpointTurnCountByTurnId } from "../session-logic"; -import type { Thread } from "../types"; +import type { Thread, TurnDiffSummary } from "../types"; -export function useTurnDiffSummaries(activeThread: Thread | undefined) { - const turnDiffSummaries = useMemo(() => { +export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { + const turnDiffSummaries = useMemo>(() => { if (!activeThread) { return []; } - return activeThread.turnDiffSummaries; + return activeThread.checkpoints; }, [activeThread]); const inferredCheckpointTurnCountByTurnId = useMemo( diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index f465b620a28..b39123e0eac 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -1,23 +1,62 @@ import { useAtomValue } from "@effect/atom-react"; import { type ArchivedSnapshotEntry, - createArchivedThreadsManager, makeArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "@t3tools/client-runtime"; + parseArchivedThreadsEnvironmentKey, +} from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; -import { readEnvironmentApi } from "../environmentApi"; +import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; -const archivedThreadsManager = createArchivedThreadsManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, -}); +const archivedSnapshotsAtom = Atom.family((environmentKey: string) => + Atom.make((get) => { + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + let isLoading = false; + + for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { + const result = get( + orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }), + ); + isLoading ||= result.waiting; + const snapshot = Option.getOrNull(AsyncResult.value(result)); + if (snapshot !== null) { + snapshots.push({ environmentId, snapshot }); + } + if (error === null && result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = + cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load archived threads."; + } + } + + return { + snapshots, + error, + isLoading, + }; + }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)), +); + +function archivedSnapshotAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }); +} export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { - archivedThreadsManager.refreshForEnvironment(environmentId); + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { @@ -30,14 +69,15 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray makeArchivedThreadsEnvironmentKey(environmentIds), [environmentIds], ); - const atom = archivedThreadsManager.getAtom(environmentKey); - const result = useAtomValue(atom); + const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); const refresh = useCallback(() => { - archivedThreadsManager.refresh(environmentIds); + for (const environmentId of environmentIds) { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); + } }, [environmentIds]); return { - ...readArchivedThreadsSnapshotState(result), + ...result, refresh, }; } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 56c7508f9e5..45d22b1df91 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 39826e8af3d..b434d1f519f 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; diff --git a/apps/web/src/lib/checkpointDiffState.ts b/apps/web/src/lib/checkpointDiffState.ts index afd38b84e5d..067e22d51df 100644 --- a/apps/web/src/lib/checkpointDiffState.ts +++ b/apps/web/src/lib/checkpointDiffState.ts @@ -1,63 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffState, type CheckpointDiffTarget, - checkpointDiffStateAtom, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_ATOM, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { readEnvironmentApi } from "../environmentApi"; -import { subscribeProviderInvalidations } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, -}); - -export function invalidateCheckpointDiffs(): void { - checkpointDiffManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateCheckpointDiffs); +import { useCheckpointDiff as useCheckpointDiffQuery } from "../state/queries"; export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, ): CheckpointDiffState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - cacheScope: target.cacheScope ?? null, - }), - [ - target.cacheScope, - target.environmentId, - target.fromTurnCount, - target.ignoreWhitespace, - target.threadId, - target.toTurnCount, - ], - ); - const targetKey = getCheckpointDiffTargetKey(stableTarget); - - useEffect(() => { - if (targetKey === null || options?.enabled === false) { - return; - } - void checkpointDiffManager.load(stableTarget); - }, [options?.enabled, stableTarget, targetKey]); - - const state = useAtomValue( - targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM, - ); - return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state; + const state = useCheckpointDiffQuery(target, options); + return { + data: state.data, + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/composerPathSearchState.ts b/apps/web/src/lib/composerPathSearchState.ts index e25f60ad13d..a2ad55c6775 100644 --- a/apps/web/src/lib/composerPathSearchState.ts +++ b/apps/web/src/lib/composerPathSearchState.ts @@ -1,57 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; import { type ComposerPathSearchState, type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const COMPOSER_PATH_SEARCH_LIMIT = 80; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - limit: COMPOSER_PATH_SEARCH_LIMIT, - debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function invalidateComposerPathSearches(): void { - composerPathSearchManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateComposerPathSearches); +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; + const state = useComposerPathSearchQuery(target); + return { + entries: state.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })), + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/desktopUpdateReactQuery.test.ts b/apps/web/src/lib/desktopUpdateReactQuery.test.ts deleted file mode 100644 index 5f53f77a3ae..00000000000 --- a/apps/web/src/lib/desktopUpdateReactQuery.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it } from "vite-plus/test"; -import type { DesktopUpdateState } from "@t3tools/contracts"; -import { - desktopUpdateQueryKeys, - desktopUpdateStateQueryOptions, - setDesktopUpdateStateQueryData, -} from "./desktopUpdateReactQuery"; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - channel: "latest", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("desktopUpdateStateQueryOptions", () => { - it("always refetches on mount so Settings does not reuse stale desktop update state", () => { - const options = desktopUpdateStateQueryOptions(); - - expect(options.staleTime).toBe(Infinity); - expect(options.refetchOnMount).toBe("always"); - }); -}); - -describe("setDesktopUpdateStateQueryData", () => { - it("writes desktop update state into the shared cache key", () => { - const queryClient = new QueryClient(); - const nextState: DesktopUpdateState = { - ...baseState, - status: "downloaded", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - }; - - setDesktopUpdateStateQueryData(queryClient, nextState); - - expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); - }); -}); diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts deleted file mode 100644 index 9315772786a..00000000000 --- a/apps/web/src/lib/desktopUpdateReactQuery.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -export const desktopUpdateQueryKeys = { - all: ["desktop", "update"] as const, - state: () => ["desktop", "update", "state"] as const, -}; - -export const setDesktopUpdateStateQueryData = ( - queryClient: QueryClient, - state: DesktopUpdateState | null, -) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); - -export function desktopUpdateStateQueryOptions() { - return queryOptions({ - queryKey: desktopUpdateQueryKeys.state(), - queryFn: async () => { - const bridge = window.desktopBridge; - if (!bridge || typeof bridge.getUpdateState !== "function") return null; - return bridge.getUpdateState(); - }, - staleTime: Infinity, - refetchOnMount: "always", - }); -} - -export function useDesktopUpdateState() { - const queryClient = useQueryClient(); - const query = useQuery(desktopUpdateStateQueryOptions()); - - useEffect(() => { - const bridge = window.desktopBridge; - if (!bridge || typeof bridge.onUpdateState !== "function") return; - - return bridge.onUpdateState((nextState) => { - setDesktopUpdateStateQueryData(queryClient, nextState); - }); - }, [queryClient]); - - return query; -} diff --git a/apps/web/src/lib/processDiagnosticsState.ts b/apps/web/src/lib/processDiagnosticsState.ts deleted file mode 100644 index 7e1b3d698a6..00000000000 --- a/apps/web/src/lib/processDiagnosticsState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { - ServerProcessDiagnosticsResult, - ServerProcessResourceHistoryResult, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000; -const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; -const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000; -const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":"; - -const processDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()), -).pipe( - Atom.swr({ - staleTime: PROCESS_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("process-diagnostics"), -); - -function formatProcessResourceHistoryKey(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): string { - return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; -} - -function parseProcessResourceHistoryKey(key: string): { - readonly windowMs: number; - readonly bucketMs: number; -} { - const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR); - return { - windowMs: Number(windowMs), - bucketMs: Number(bucketMs), - }; -} - -const processResourceHistoryAtom = Atom.family((key: string) => { - const input = parseProcessResourceHistoryKey(key); - return Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)), - ).pipe( - Atom.swr({ - staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel(`process-resource-history:${key}`), - ); -}); - -export interface ProcessDiagnosticsState { - readonly data: ServerProcessDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -export interface ProcessResourceHistoryState { - readonly data: ServerProcessResourceHistoryResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatProcessDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load process diagnostics."; -} - -function readProcessDiagnosticsError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -function readProcessResourceHistoryError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -export function refreshProcessDiagnostics(): void { - appAtomRegistry.refresh(processDiagnosticsAtom); -} - -export function useProcessDiagnostics(): ProcessDiagnosticsState { - const result = useAtomValue(processDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshProcessDiagnostics(); - }, []); - - return { - data, - error: readProcessDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} - -export function useProcessResourceHistory(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): ProcessResourceHistoryState { - const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input)); - const result = useAtomValue(atom); - const data = Option.getOrNull(AsyncResult.value(result)); - - const refresh = useCallback(() => { - appAtomRegistry.refresh(atom); - }, [atom]); - - return { - data, - error: readProcessResourceHistoryError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts index da0233ccfb3..262095c663c 100644 --- a/apps/web/src/lib/projectPaths.ts +++ b/apps/web/src/lib/projectPaths.ts @@ -14,4 +14,4 @@ export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch, resolveProjectPathForDispatch, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 1d7903ced06..728163b2491 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -2,8 +2,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { @@ -13,22 +14,22 @@ import { import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; import { browserCryptoLayer } from "../cloud/dpop"; -import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } -const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); -const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { +const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); +const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { serviceName: "t3-web-relay-client", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", -}).pipe(Layer.provide(webHttpClientLayer)); +}).pipe(Layer.provide(httpClientLayer)); -export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); +export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( primaryEnvironmentHttpClientLive.pipe( @@ -58,13 +59,16 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const webRuntime = ManagedRuntime.make( - Layer.mergeAll( - webHttpClientLayer, - browserCryptoLayer, - webManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), - Layer.provideMerge(webRelayTracingLayer), - ), +export const runtimeLayer = Layer.mergeAll( + httpClientLayer, + browserCryptoLayer, + Socket.layerWebSocketConstructorGlobal, + relayTracingLayer, + managedRelayClientLayer(configuredRelayUrl()).pipe( + Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), ), ); + +export const runtime = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 917b8c3a9b2..2d857c8e4b7 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -1,477 +1,10 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsActionOperation, - type VcsActionState, - EMPTY_VCS_ACTION_ATOM, - EMPTY_VCS_ACTION_STATE, - createVcsActionManager, - getVcsActionTargetKey, - vcsActionStateAtom, -} from "@t3tools/client-runtime"; -import { - type EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type GitStackedAction, - type GitResolvePullRequestResult, - type SourceControlCloneProtocol, - type SourceControlPublishRepositoryResult, - type SourceControlRepositoryVisibility, - type ThreadId, - type VcsPullResult, -} from "@t3tools/contracts"; -import { - useCallback, - useEffect, - useMemo, - useState, - useSyncExternalStore, - useTransition, -} from "react"; - -import { ensureEnvironmentApi } from "../environmentApi"; -import { readEnvironmentConnection } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState"; -import { vcsRefManager } from "./vcsRefState"; - -type SourceControlActionKind = - | "init" - | "pull" - | "publishRepository" - | "runStackedAction" - | "preparePullRequestThread"; - -interface SourceControlActionScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -interface SourceControlActionState, TResult> { - readonly isPending: boolean; - readonly error: unknown; - readonly run: (...args: TArgs) => Promise; - readonly resetError: () => void; -} - -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = readEnvironmentConnection(environmentId)?.client; - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; - }, - onInvalidate: (target) => invalidateSourceControlState(target), -}); - -const actionListeners = new Set<() => void>(); -const activeActionCounts = new Map(); - -function notifyActionListeners(): void { - for (const listener of actionListeners) { - listener(); - } -} - -function subscribeActionState(listener: () => void): () => void { - actionListeners.add(listener); - return () => { - actionListeners.delete(listener); - }; -} - -function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { - return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; -} - -function beginAction(key: string): () => void { - activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); - notifyActionListeners(); - let ended = false; - return () => { - if (ended) { - return; - } - ended = true; - const next = (activeActionCounts.get(key) ?? 1) - 1; - if (next <= 0) { - activeActionCounts.delete(key); - } else { - activeActionCounts.set(key, next); - } - notifyActionListeners(); - }; -} - -function isAnyActionRunning( - kinds: ReadonlyArray, - scope: SourceControlActionScope, -): boolean { - return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0); -} - -function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null { - switch (kind) { - case "init": - return "init"; - case "pull": - return "pull"; - case "runStackedAction": - return "run_change_request"; - case "publishRepository": - case "preparePullRequestThread": - return null; - } -} - -function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState { - const targetKey = getVcsActionTargetKey(scope); - const state = useAtomValue( - targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, - ); - return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; -} - -export function invalidateSourceControlState(scope?: { - readonly environmentId?: EnvironmentId | null; - readonly cwd?: string | null; -}): Promise { - const environmentId = scope?.environmentId ?? null; - const cwd = scope?.cwd ?? null; - if (cwd !== null) { - vcsRefManager.invalidateScope({ environmentId, cwd }); - if (environmentId !== null) { - return refreshVcsStatus({ environmentId, cwd }).then( - () => undefined, - () => undefined, - ); - } - return Promise.resolve(); - } - - vcsRefManager.invalidate(); - return Promise.resolve(); -} - -function useSourceControlAction, TResult>(input: { - readonly kind: SourceControlActionKind; - readonly scope: SourceControlActionScope; - readonly action: (...args: TArgs) => Promise; - readonly invalidateOnSuccess?: boolean; -}): SourceControlActionState { - const { action, invalidateOnSuccess = true, kind, scope } = input; - const [error, setError] = useState(null); - const [activeCount, setActiveCount] = useState(0); - const [isTransitionPending, startTransition] = useTransition(); - const key = actionKey(kind, scope); - - const resetError = useCallback(() => { - startTransition(() => setError(null)); - }, [startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise => { - const endAction = beginAction(key); - startTransition(() => { - setError(null); - setActiveCount((count) => count + 1); - }); - try { - const result = await action(...args); - if (invalidateOnSuccess) { - await invalidateSourceControlState(scope); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } finally { - endAction(); - startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); - } - }, - [action, invalidateOnSuccess, key, scope, startTransition], - ); - - return { - error, - isPending: activeCount > 0 || isTransitionPending, - resetError, - run, - }; -} - -export function useSourceControlActionRunning( - scope: SourceControlActionScope, - kinds: ReadonlyArray, -): boolean { - const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); - const appActionRunning = useSyncExternalStore( - subscribeActionState, - () => isAnyActionRunning(stableKinds, scope), - () => false, - ); - const vcsActionState = useVcsActionStateForScope(scope); - const vcsActionRunning = - vcsActionState.isRunning && - stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation); - - return appActionRunning || vcsActionRunning; -} - -function useVcsManagerAction, TResult>(input: { - readonly operation: VcsActionOperation; - readonly scope: SourceControlActionScope; - readonly unavailableMessage: string; - readonly action: (...args: TArgs) => Promise; -}): SourceControlActionState { - const { action, operation, scope, unavailableMessage } = input; - const vcsActionState = useVcsActionStateForScope(scope); - const [error, setError] = useState(null); - const [isTransitionPending, startTransition] = useTransition(); - - const resetError = useCallback(() => { - vcsActionManager.reset(scope); - startTransition(() => setError(null)); - }, [scope, startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise => { - startTransition(() => setError(null)); - try { - const result = await action(...args); - if (result === null) { - throw new Error(unavailableMessage); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } - }, - [action, startTransition, unavailableMessage], - ); - - return { - error: error ?? vcsActionState.error, - isPending: - isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation), - resetError, - run, - }; -} - -export function useVcsInitAction(scope: SourceControlActionScope) { - const action = useCallback(async () => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable."); - return vcsActionManager.init(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "init", - scope, - unavailableMessage: "Git init is unavailable.", - action, - }); -} - -export function useGitStackedAction(scope: SourceControlActionScope) { - const action = useCallback( - async ({ - actionId, - action, - commitMessage, - featureBranch, - filePaths, - onProgress, - }: { - actionId: string; - action: GitStackedAction; - commitMessage?: string; - featureBranch?: boolean; - filePaths?: string[]; - onProgress?: (event: GitActionProgressEvent) => void; - }): Promise => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable."); - return vcsActionManager.runChangeRequest( - scope, - { - actionId, - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch: true } : {}), - ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), - }, - { - gitStatus: getVcsStatusSnapshot(scope).data, - ...(onProgress ? { onProgress } : {}), - }, - ); - }, - [scope], - ); - - return useVcsManagerAction({ - operation: "run_change_request", - scope, - unavailableMessage: "Git action is unavailable.", - action, - }); -} - -export function useVcsPullAction(scope: SourceControlActionScope) { - const action = useCallback(async (): Promise => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable."); - return vcsActionManager.pull(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "pull", - scope, - unavailableMessage: "Git pull is unavailable.", - action, - }); -} - -export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; - repository: string; - visibility: SourceControlRepositoryVisibility; - remoteName: string; - protocol: SourceControlCloneProtocol; - }): Promise => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Repository publishing is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({ - cwd: scope.cwd, - ...args, - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "publishRepository", - scope, - action, - }); -} - -export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Pull request thread preparation is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({ - cwd: scope.cwd, - reference: args.reference, - mode: args.mode, - ...(args.threadId ? { threadId: args.threadId } : {}), - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "preparePullRequestThread", - scope, - action, - }); -} - -interface PullRequestResolutionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly reference: string | null; -} - -interface PullRequestResolutionState { - readonly data: GitResolvePullRequestResult | null; - readonly error: unknown; - readonly isPending: boolean; - readonly isFetching: boolean; -} - -const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = { - data: null, - error: null, - isPending: false, - isFetching: false, -}; - -const pullRequestResolutionCache = new Map(); - -function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { - if (!target.environmentId || !target.cwd || !target.reference) { - return null; - } - return `${target.environmentId}:${target.cwd}:${target.reference}`; -} - -export function readCachedPullRequestResolution( - target: PullRequestResolutionTarget, -): GitResolvePullRequestResult | null { - const key = pullRequestResolutionKey(target); - return key ? (pullRequestResolutionCache.get(key) ?? null) : null; -} - -export function usePullRequestResolution( - target: PullRequestResolutionTarget, -): PullRequestResolutionState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - reference: target.reference, - }), - [target.cwd, target.environmentId, target.reference], - ); - const key = pullRequestResolutionKey(stableTarget); - const [state, setState] = useState(() => { - const cached = readCachedPullRequestResolution(stableTarget); - return cached - ? { data: cached, error: null, isPending: false, isFetching: false } - : EMPTY_PULL_REQUEST_RESOLUTION; - }); - - useEffect(() => { - if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) { - setState(EMPTY_PULL_REQUEST_RESOLUTION); - return; - } - - const cached = pullRequestResolutionCache.get(key) ?? null; - setState({ - data: cached, - error: null, - isPending: cached === null, - isFetching: true, - }); - - let cancelled = false; - ensureEnvironmentApi(stableTarget.environmentId) - .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference }) - .then((result) => { - if (cancelled) { - return; - } - pullRequestResolutionCache.set(key, result); - setState({ data: result, error: null, isPending: false, isFetching: false }); - }) - .catch((error: unknown) => { - if (cancelled) { - return; - } - setState({ data: cached, error, isPending: false, isFetching: false }); - }); - - return () => { - cancelled = true; - }; - }, [key, stableTarget]); - - return state; -} +export { + readCachedPullRequestResolution, + useGitStackedAction, + usePreparePullRequestThreadAction, + usePullRequestResolutionState as usePullRequestResolution, + useSourceControlActionRunning, + useSourceControlPublishRepositoryAction, + useVcsInitAction, + useVcsPullAction, +} from "../state/sourceControlActions"; diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts deleted file mode 100644 index 133f09d252a..00000000000 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type SourceControlDiscoveryTarget, - type SourceControlDiscoveryState, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { readLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; -const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; -const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; - -interface SourceControlDiscoveryTargetInput { - readonly environmentId?: EnvironmentId | null; -} - -function sourceControlDiscoveryTarget( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryTarget { - const environmentId = input?.environmentId ?? null; - if (!environmentId) { - return SOURCE_CONTROL_DISCOVERY_TARGET; - } - return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId - ? SOURCE_CONTROL_DISCOVERY_TARGET - : { key: environmentId }; -} - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (key) => { - if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { - const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; - const primaryConnection = primaryEnvironmentId - ? readEnvironmentConnection(primaryEnvironmentId) - : null; - if (primaryConnection) { - return primaryConnection.client.server; - } - try { - return readLocalApi()?.server ?? null; - } catch { - return null; - } - } - const environmentId = EnvironmentId.make(key); - const connection = readEnvironmentConnection(environmentId); - if (connection) { - return connection.client.server; - } - return null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, - idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS, -}); - -export function refreshSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): Promise { - return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); -} - -export function getSourceControlDiscoverySnapshot( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - sourceControlDiscoveryManager.reset(); -} - -export function useSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - const targetKey = - getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? - SOURCE_CONTROL_DISCOVERY_TARGET.key; - - useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]); - - return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); -} diff --git a/apps/web/src/lib/terminalUiStateCleanup.test.ts b/apps/web/src/lib/terminalUiStateCleanup.test.ts index f96436fc976..a7fa1c1d317 100644 --- a/apps/web/src/lib/terminalUiStateCleanup.test.ts +++ b/apps/web/src/lib/terminalUiStateCleanup.test.ts @@ -1,4 +1,4 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ad4126e4b30..b9981bc2e3e 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -16,7 +16,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: LOCAL_ENVIRONMENT_ID, - codexThreadId: null, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, @@ -25,14 +24,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], ...overrides, }; @@ -50,9 +49,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "user", text: "older", + turnId: null, createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", }, ], }), @@ -65,9 +65,10 @@ describe("sortThreads", () => { id: "message-2" as never, role: "user", text: "newer", + turnId: null, createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", }, ], }), @@ -92,9 +93,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "assistant", text: "assistant only", + turnId: null, createdAt: "2026-03-09T10:02:00.000Z", + updatedAt: "2026-03-09T10:02:00.000Z", streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", }, ], }), @@ -144,14 +146,14 @@ describe("sortThreads", () => { [ makeThread({ id: ThreadId.make("thread-1"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), makeThread({ id: ThreadId.make("thread-2"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), ], diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index de8b22e93c5..9dc31cbbca5 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -4,7 +4,7 @@ import type { Thread } from "../types"; export type ThreadSortInput = Pick & { latestUserMessageAt?: string | null; - messages?: Pick[]; + messages?: ReadonlyArray>; }; export function toSortableTimestamp(iso: string | undefined): number | null { diff --git a/apps/web/src/lib/traceDiagnosticsState.ts b/apps/web/src/lib/traceDiagnosticsState.ts deleted file mode 100644 index 73d9a6c3949..00000000000 --- a/apps/web/src/lib/traceDiagnosticsState.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { ServerTraceDiagnosticsResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const TRACE_DIAGNOSTICS_STALE_TIME_MS = 5_000; -const TRACE_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; - -const traceDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getTraceDiagnostics()), -).pipe( - Atom.swr({ - staleTime: TRACE_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(TRACE_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("trace-diagnostics"), -); - -export interface TraceDiagnosticsState { - readonly data: ServerTraceDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatTraceDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load trace diagnostics."; -} - -function readTraceDiagnosticsError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatTraceDiagnosticsError(squashed); -} - -export function refreshTraceDiagnostics(): void { - appAtomRegistry.refresh(traceDiagnosticsAtom); -} - -export function useTraceDiagnostics(): TraceDiagnosticsState { - const result = useAtomValue(traceDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshTraceDiagnostics(); - }, []); - - return { - data, - error: readTraceDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 555fc71f01f..47b428bc3a2 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -5,9 +5,9 @@ import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; describe("summarizeTurnDiffStats", () => { it("sums only files with numeric additions/deletions", () => { const stat = summarizeTurnDiffStats([ - { path: "README.md", additions: 3, deletions: 1 }, - { path: "docs/notes.md" }, - { path: "src/index.ts", additions: 5, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 3, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 5, deletions: 2 }, ]); expect(stat).toEqual({ additions: 8, deletions: 3 }); @@ -17,9 +17,9 @@ describe("summarizeTurnDiffStats", () => { describe("buildTurnDiffTree", () => { it("builds nested directory nodes with aggregated stats", () => { const tree = buildTurnDiffTree([ - { path: "src/index.ts", additions: 2, deletions: 1 }, - { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, - { path: "README.md", additions: 1, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", kind: "modified", additions: 4, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, ]); expect(tree).toEqual([ @@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => { ]); }); - it("keeps files without stat values and excludes them from directory totals", () => { + it("keeps zero-valued file stats and includes only their numeric contribution", () => { const tree = buildTurnDiffTree([ - { path: "docs/notes.md" }, - { path: "docs/todo.md", additions: 1, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "docs/todo.md", kind: "modified", additions: 1, deletions: 1 }, ]); expect(tree).toEqual([ @@ -77,7 +77,7 @@ describe("buildTurnDiffTree", () => { kind: "file", name: "notes.md", path: "docs/notes.md", - stat: null, + stat: { additions: 0, deletions: 0 }, }, { kind: "file", @@ -92,7 +92,7 @@ describe("buildTurnDiffTree", () => { it("normalizes file paths with windows separators", () => { const tree = buildTurnDiffTree([ - { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }, + { path: "apps\\web\\src\\index.ts", kind: "modified", additions: 2, deletions: 1 }, ]); expect(tree).toEqual([ @@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => { it("compacts only single-directory chains and stops at branch points", () => { const tree = buildTurnDiffTree([ - { path: "apps/server/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/server/main.ts", additions: 4, deletions: 0 }, + { path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 }, ]); expect(tree).toEqual([ @@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => { it("preserves leading/trailing whitespace in path segments", () => { const tree = buildTurnDiffTree([ - { path: "a/file.ts", additions: 1, deletions: 0 }, - { path: " a/file.ts", additions: 2, deletions: 0 }, + { path: "a/file.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: " a/file.ts", kind: "modified", additions: 2, deletions: 0 }, ]); expect(tree).toHaveLength(2); diff --git a/apps/web/src/lib/vcsRefState.ts b/apps/web/src/lib/vcsRefState.ts deleted file mode 100644 index 8addc03c5f7..00000000000 --- a/apps/web/src/lib/vcsRefState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/web/src/lib/vcsStatusState.ts b/apps/web/src/lib/vcsStatusState.ts deleted file mode 100644 index 6d0bba3bdcc..00000000000 --- a/apps/web/src/lib/vcsStatusState.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusClient, - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusDataForTarget, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -export type { VcsStatusState, VcsStatusTarget }; -export { getVcsStatusDataForTarget }; - -const manager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.client.vcs : null; - }, - getClientIdentity: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState { - return manager.getSnapshot(target); -} - -export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - return manager.watch(target, client); -} - -export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) { - return manager.refresh(target, client); -} - -export function resetVcsStatusStateForTests(): void { - manager.reset(); -} - -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - useEffect( - () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 893a79da194..3379f5ed989 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -1,23 +1,10 @@ import { - CommandId, - DEFAULT_SERVER_SETTINGS, + DEFAULT_CLIENT_SETTINGS, + type ContextMenuItem, type DesktopBridge, - EnvironmentId, - type VcsStatusResult, - ProjectId, - type OrchestrationShellStreamItem, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerProvider, - type TerminalAttachStreamEvent, - type TerminalMetadataStreamEvent, - ThreadId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ContextMenuItem } from "@t3tools/contracts"; - const showContextMenuFallbackMock = vi.fn< ( @@ -26,344 +13,44 @@ const showContextMenuFallbackMock = ) => Promise >(); -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); -const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); -const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); -const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); - -const rpcClientMock = { - dispose: vi.fn(), - terminal: { - open: vi.fn(), - attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => - registerListener(terminalAttachListeners, listener), - ), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) => - registerListener(terminalMetadataListeners, listener), - ), - }, - projects: { - listEntries: vi.fn(), - readFile: vi.fn(), - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - assets: { - createUrl: vi.fn(), - }, - preview: { - open: vi.fn(), - navigate: vi.fn(), - refresh: vi.fn(), - close: vi.fn(), - list: vi.fn(), - reportStatus: vi.fn(), - automation: { - connect: vi.fn(() => () => undefined), - respond: vi.fn(), - reportOwner: vi.fn(), - clearOwner: vi.fn(), - }, - onEvent: vi.fn(() => () => undefined), - subscribePorts: vi.fn(() => () => undefined), - }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(gitStatusListeners, listener), - ), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(), - subscribeLifecycle: vi.fn(), - subscribeAuthAccess: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => - registerListener(shellStreamListeners, listener), - ), - subscribeThread: vi.fn(() => () => undefined), - }, -}; - -vi.mock("./environments/runtime", () => ({ - getPrimaryEnvironmentConnection: () => ({ - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Primary", - source: "manual" as const, - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - environmentId: EnvironmentId.make("environment-local"), - }, - client: rpcClientMock, - environmentId: EnvironmentId.make("environment-local"), - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - resetEnvironmentServiceForTests: vi.fn(), - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - subscribeEnvironmentConnections: vi.fn(() => () => undefined), -})); - vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); -function emitEvent(listeners: Set<(event: T) => void>, event: T) { - for (const listener of listeners) { - listener(event); - } -} - -function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { - const testGlobal = globalThis as typeof globalThis & { - window?: Window & typeof globalThis & { desktopBridge?: unknown }; - }; - if (!testGlobal.window) { - testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; - } - return testGlobal.window; -} - function createLocalStorageStub(): Storage { - const store = new Map(); + const values = new Map(); return { - getItem: (key) => store.get(key) ?? null, + getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { - store.set(key, value); + values.set(key, value); }, removeItem: (key) => { - store.delete(key); + values.delete(key); }, - clear: () => { - store.clear(); - }, - key: (index) => [...store.keys()][index] ?? null, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, get length() { - return store.size; + return values.size; }, }; } -function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { - return { - getAppBranding: () => null, - getLocalEnvironmentBootstrap: () => null, - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - discoverSshHosts: async () => [], - ensureSshEnvironment: async () => { - throw new Error("ensureSshEnvironment not implemented in test"); - }, - disconnectSshEnvironment: async () => undefined, - fetchSshEnvironmentDescriptor: async () => { - throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); - }, - bootstrapSshBearerSession: async () => { - throw new Error("bootstrapSshBearerSession not implemented in test"); - }, - fetchSshSessionState: async () => { - throw new Error("fetchSshSessionState not implemented in test"); - }, - issueSshWebSocketTicket: async () => { - throw new Error("issueSshWebSocketTicket not implemented in test"); - }, - onSshPasswordPrompt: () => () => undefined, - resolveSshPasswordPrompt: async () => undefined, - getServerExposureState: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setServerExposureMode: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setTailscaleServeEnabled: async (input) => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: input.enabled, - tailscaleServePort: input.port ?? 443, - }), - getAdvertisedEndpoints: async () => [], - pickFolder: async () => null, - confirm: async () => true, - setTheme: async () => undefined, - showContextMenu: async () => null, - openExternal: async () => true, - createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", - getCloudAuthToken: async () => null, - setCloudAuthToken: async () => true, - clearCloudAuthToken: async () => undefined, - fetchCloudAuth: async () => ({ - ok: true, - status: 200, - statusText: "OK", - headers: {}, - body: "", - }), - onCloudAuthCallback: () => () => undefined, - onMenuAction: () => () => undefined, - getUpdateState: async () => { - throw new Error("getUpdateState not implemented in test"); - }, - setUpdateChannel: async () => { - throw new Error("setUpdateChannel not implemented in test"); - }, - checkForUpdate: async () => { - throw new Error("checkForUpdate not implemented in test"); - }, - downloadUpdate: async () => { - throw new Error("downloadUpdate not implemented in test"); - }, - installUpdate: async () => { - throw new Error("installUpdate not implemented in test"); - }, - onUpdateState: () => () => undefined, - ...overrides, - }; +function testWindow(): Window & typeof globalThis { + return globalThis.window ?? (globalThis as unknown as Window & typeof globalThis); } -const defaultProviders: ReadonlyArray = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const baseGitStatus: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/streamed", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - showContextMenuFallbackMock.mockReset(); - terminalAttachListeners.clear(); - terminalMetadataListeners.clear(); - shellStreamListeners.clear(); - gitStatusListeners.clear(); - const testWindow = getWindowForTest(); - Reflect.deleteProperty(testWindow, "desktopBridge"); - Object.defineProperty(testWindow, "localStorage", { + if (globalThis.window === undefined) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + } + Reflect.deleteProperty(testWindow(), "desktopBridge"); + Reflect.deleteProperty(testWindow(), "nativeApi"); + Object.defineProperty(testWindow(), "localStorage", { configurable: true, value: createLocalStorageStub(), }); @@ -373,411 +60,72 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("wsApi", () => { - it("forwards server config fetches directly to the RPC client", async () => { - rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); - expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); - expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); - expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); - }); - - it("forwards terminal attach, metadata, and shell stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onTerminalAttachEvent = vi.fn(); - const onTerminalMetadataEvent = vi.fn(); - const onShellEvent = vi.fn(); - - api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); - api.terminal.onMetadata(onTerminalMetadataEvent); - api.orchestration.subscribeShell(onShellEvent); - - const terminalAttachEvent = { - threadId: "thread-1", - terminalId: "terminal-1", - type: "output", - data: "hello", - } satisfies TerminalAttachStreamEvent; - emitEvent(terminalAttachListeners, terminalAttachEvent); - - const terminalMetadataEvent = { - type: "upsert", - terminal: { - threadId: "thread-1", - terminalId: "terminal-1", - cwd: "/tmp/workspace", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: true, - label: "terminal-1", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies TerminalMetadataStreamEvent; - emitEvent(terminalMetadataListeners, terminalMetadataEvent); - - const shellEvent = { - kind: "project-upserted" as const, - sequence: 1, - project: { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/workspace", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - scripts: [], - createdAt: "2026-02-24T00:00:00.000Z", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies OrchestrationShellStreamItem; - emitEvent(shellStreamListeners, shellEvent); - - expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); - expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); - expect(onShellEvent).toHaveBeenCalledWith(shellEvent); - }); - - it("forwards git status stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onStatus = vi.fn(); - - api.vcs.onStatus({ cwd: "/repo" }, onStatus); - - const gitStatus = baseGitStatus; - emitEvent(gitStatusListeners, gitStatus); - - expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); - expect(onStatus).toHaveBeenCalledWith(gitStatus); - }); - - it("forwards git status refreshes directly to the RPC client", async () => { - rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - - await api.vcs.refreshStatus({ cwd: "/repo" }); - - expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - }); - - it("forwards shell stream subscription options to the RPC client", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onShellEvent = vi.fn(); - const onResubscribe = vi.fn(); - - api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); - - expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { - onResubscribe, - }); - }); - - it("sends orchestration dispatch commands as the direct RPC payload", async () => { - rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const command = { - type: "project.create", - commandId: CommandId.make("cmd-1"), - projectId: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-24T00:00:00.000Z", - } as const; - await api.orchestration.dispatchCommand(command); - - expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); - }); - - it("forwards workspace file writes to the project RPC", async () => { - rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.projects.writeFile({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - - expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - }); - - it("forwards filesystem browse requests to the RPC client", async () => { - rpcClientMock.filesystem.browse.mockResolvedValue({ - parentPath: "/tmp/project/", - entries: [], - }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.filesystem.browse({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - - expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - }); - - it("forwards full-thread diff requests to the orchestration RPC", async () => { - rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.orchestration.getFullThreadDiff({ - threadId: ThreadId.make("thread-1"), - toTurnCount: 1, - }); - - expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: "thread-1", - toTurnCount: 1, - }); - }); - - it("forwards provider refreshes directly to the RPC client", async () => { - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - checkedAt: "2026-01-03T00:00:00.000Z", - }, - ]; - rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); - expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); - }); - - it("forwards provider updates directly to the RPC client", async () => { - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - updateState: { - status: "succeeded", - startedAt: "2026-01-03T00:00:00.000Z", - finishedAt: "2026-01-03T00:00:01.000Z", - message: "Provider updated.", - output: null, - }, - }, - ]; - rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect( - api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), - ).resolves.toEqual({ - providers: nextProviders, - }); - expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ - provider: ProviderDriverKind.make("codex"), - }); - }); - - it("forwards server settings updates directly to the RPC client", async () => { - const nextSettings = { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }; - rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); +describe("LocalApi", () => { + it("keeps backend operations unavailable in the browser facade", async () => { const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( - nextSettings, + await expect(api.server.getConfig()).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ - enableAssistantStreaming: true, - }); - }); - - it("forwards context menu metadata to the desktop bridge", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); - getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const items = [{ id: "delete", label: "Delete" }] as const; - - await expect(api.contextMenu.show(items)).resolves.toBe("delete"); - expect(showContextMenu).toHaveBeenCalledWith(items, undefined); - }); - - it("forwards folder picker options to the desktop bridge", async () => { - const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); - getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( - "/tmp/project", + await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); }); - it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { + it("uses the browser context-menu fallback without a desktop bridge", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); const items = [{ id: "rename", label: "Rename" }] as const; - await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); + await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); - it("reads and writes persistence through the desktop bridge when available", async () => { - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, - }; - const getClientSettings = vi.fn().mockResolvedValue({ - ...clientSettings, - }); + it("delegates host capabilities and persistence to the desktop bridge", async () => { + const showContextMenu = vi.fn().mockResolvedValue("delete"); + const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); + const getClientSettings = vi.fn().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); const setClientSettings = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); - const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); - const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); - const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); - getWindowForTest().desktopBridge = makeDesktopBridge({ + testWindow().desktopBridge = { + showContextMenu, + pickFolder, getClientSettings, setClientSettings, - getSavedEnvironmentRegistry, - setSavedEnvironmentRegistry, - getSavedEnvironmentSecret, - setSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - }); + } as unknown as DesktopBridge; const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); + const api = createLocalApi(); + const items = [{ id: "delete", label: "Delete" }] as const; - await api.persistence.getClientSettings(); - await api.persistence.setClientSettings(clientSettings); - await api.persistence.getSavedEnvironmentRegistry(); - await api.persistence.setSavedEnvironmentRegistry([]); - await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", - ); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + await expect(api.contextMenu.show(items)).resolves.toBe("delete"); + await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); + await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); + await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); - expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith(clientSettings); - expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); - expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); - expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); - expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); + expect(showContextMenu).toHaveBeenCalledWith(items, undefined); + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); + expect(getClientSettings).toHaveBeenCalledTimes(1); + expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); - it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { + it("persists client settings in browser storage", async () => { const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, + const api = createLocalApi(); + const settings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, }; - await api.persistence.setClientSettings(clientSettings); - await api.persistence.setSavedEnvironmentRegistry([ - { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }, - ]); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", - ); - - await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); - await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ - { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }, - ]); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBe("bearer-token"); + await api.persistence.setClientSettings(settings); + await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); + }); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + it("prefers the native LocalApi when one is injected", async () => { + const nativeApi = { dialogs: {} }; + testWindow().nativeApi = nativeApi as never; + const { readLocalApi } = await import("./localApi"); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBeNull(); + expect(readLocalApi()).toBe(nativeApi); }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..2fbf183f91b 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,30 +1,8 @@ import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; -import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; -import { resetServerStateForTests } from "./rpc/serverState"; -import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, -} from "./environments/runtime"; -import { - getPrimaryEnvironmentConnection, - resetEnvironmentServiceForTests, -} from "./environments/runtime"; -import { getPrimaryKnownEnvironment } from "./environments/primary"; import { showContextMenuFallback } from "./contextMenuFallback"; -import { - readBrowserClientSettings, - readBrowserSavedEnvironmentRegistry, - readBrowserSavedEnvironmentSecret, - removeBrowserSavedEnvironmentSecret, - writeBrowserClientSettings, - writeBrowserSavedEnvironmentRegistry, - writeBrowserSavedEnvironmentSecret, -} from "./clientPersistenceStorage"; +import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; let cachedApi: LocalApi | undefined; @@ -32,7 +10,7 @@ function unavailableLocalBackendError(): Error { return new Error("Local backend API is unavailable before a backend is paired."); } -function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { +function createBrowserLocalApi(): LocalApi { return { dialogs: { pickFolder: async (options) => { @@ -47,10 +25,7 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { }, }, shell: { - openInEditor: (cwd, editor) => - rpcClient - ? rpcClient.shell.openInEditor({ cwd, editor }) - : Promise.reject(unavailableLocalBackendError()), + openInEditor: () => Promise.reject(unavailableLocalBackendError()), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -87,88 +62,26 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { } writeBrowserClientSettings(settings); }, - getSavedEnvironmentRegistry: async () => { - if (window.desktopBridge) { - return window.desktopBridge.getSavedEnvironmentRegistry(); - } - return readBrowserSavedEnvironmentRegistry(); - }, - setSavedEnvironmentRegistry: async (records) => { - if (window.desktopBridge) { - return window.desktopBridge.setSavedEnvironmentRegistry(records); - } - writeBrowserSavedEnvironmentRegistry(records); - }, - getSavedEnvironmentSecret: async (environmentId) => { - if (window.desktopBridge) { - return window.desktopBridge.getSavedEnvironmentSecret(environmentId); - } - return readBrowserSavedEnvironmentSecret(environmentId); - }, - setSavedEnvironmentSecret: async (environmentId, secret) => { - if (window.desktopBridge) { - return window.desktopBridge.setSavedEnvironmentSecret(environmentId, secret); - } - return writeBrowserSavedEnvironmentSecret(environmentId, secret); - }, - removeSavedEnvironmentSecret: async (environmentId) => { - if (window.desktopBridge) { - return window.desktopBridge.removeSavedEnvironmentSecret(environmentId); - } - removeBrowserSavedEnvironmentSecret(environmentId); - }, }, server: { - getConfig: () => - rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => - rpcClient - ? rpcClient.server.refreshProviders() - : Promise.reject(unavailableLocalBackendError()), - updateProvider: (input) => - rpcClient - ? rpcClient.server.updateProvider(input) - : Promise.reject(unavailableLocalBackendError()), - upsertKeybinding: (input) => - rpcClient - ? rpcClient.server.upsertKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - removeKeybinding: (input) => - rpcClient - ? rpcClient.server.removeKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - getSettings: () => - rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), - updateSettings: (patch) => - rpcClient - ? rpcClient.server.updateSettings(patch) - : Promise.reject(unavailableLocalBackendError()), - discoverSourceControl: () => - rpcClient - ? rpcClient.server.discoverSourceControl() - : Promise.reject(unavailableLocalBackendError()), - getTraceDiagnostics: () => - rpcClient - ? rpcClient.server.getTraceDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessDiagnostics: () => - rpcClient - ? rpcClient.server.getProcessDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessResourceHistory: (input) => - rpcClient - ? rpcClient.server.getProcessResourceHistory(input) - : Promise.reject(unavailableLocalBackendError()), - signalProcess: (input) => - rpcClient - ? rpcClient.server.signalProcess(input) - : Promise.reject(unavailableLocalBackendError()), + getConfig: () => Promise.reject(unavailableLocalBackendError()), + refreshProviders: () => Promise.reject(unavailableLocalBackendError()), + updateProvider: () => Promise.reject(unavailableLocalBackendError()), + upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), + removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), + getSettings: () => Promise.reject(unavailableLocalBackendError()), + updateSettings: () => Promise.reject(unavailableLocalBackendError()), + discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), + getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), + signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; } -export function createLocalApi(rpcClient: WsRpcClient): LocalApi { - return createBrowserLocalApi(rpcClient); +export function createLocalApi(): LocalApi { + return createBrowserLocalApi(); } export function readLocalApi(): LocalApi | undefined { @@ -180,10 +93,7 @@ export function readLocalApi(): LocalApi | undefined { return cachedApi; } - const primaryEnvironment = getPrimaryKnownEnvironment(); - cachedApi = primaryEnvironment - ? createLocalApi(getPrimaryEnvironmentConnection().client) - : createBrowserLocalApi(); + cachedApi = createBrowserLocalApi(); return cachedApi; } @@ -199,12 +109,5 @@ export async function __resetLocalApiForTests() { cachedApi = undefined; const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); __resetClientSettingsPersistenceForTests(); - await resetEnvironmentServiceForTests(); - resetVcsStatusStateForTests(); - resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - resetServerStateForTests(); - resetWsConnectionStateForTests(); } diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 72415d57de0..f9040dae976 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,8 +1,8 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; -import type { Project } from "./types"; export interface ProjectGroupingSettings { sidebarProjectGroupingMode: SidebarProjectGroupingMode; @@ -33,14 +33,14 @@ function uniqueNonEmptyValues(values: ReadonlyArray): } function deriveRepositoryRelativeProjectPath( - project: Pick, + project: Pick, ): string | null { const rootPath = project.repositoryIdentity?.rootPath?.trim(); if (!rootPath) { return null; } - const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); const normalizedRootPath = normalizeProjectPathForComparison(rootPath); if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { return null; @@ -63,25 +63,28 @@ export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: str return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; } -export function derivePhysicalProjectKey(project: Pick): string { - return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +export function derivePhysicalProjectKey( + project: Pick, +): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); } export function deriveProjectGroupingOverrideKey( - project: Pick, + project: Pick, ): string { return derivePhysicalProjectKey(project); } // Key under which a project's manual sort order (projectOrder) is stored. -// Must stay aligned with the writer side in `uiStateStore.syncProjects` and -// the drag handlers in `Sidebar` so readers and writers agree. -export function getProjectOrderKey(project: Pick): string { +// Must stay aligned with the drag handlers and readers in `Sidebar`. +export function getProjectOrderKey( + project: Pick, +): string { return derivePhysicalProjectKey(project); } export function resolveProjectGroupingMode( - project: Pick, + project: Pick, settings: ProjectGroupingSettings, ): SidebarProjectGroupingMode { return ( @@ -91,7 +94,7 @@ export function resolveProjectGroupingMode( } function deriveRepositoryScopedKey( - project: Pick, + project: Pick, groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; @@ -114,7 +117,10 @@ function deriveRepositoryScopedKey( } export function deriveLogicalProjectKey( - project: Pick, + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, options?: { groupingMode?: SidebarProjectGroupingMode; }, @@ -132,7 +138,10 @@ export function deriveLogicalProjectKey( } export function deriveLogicalProjectKeyFromSettings( - project: Pick, + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, settings: ProjectGroupingSettings, ): string { return deriveLogicalProjectKey(project, { @@ -142,7 +151,10 @@ export function deriveLogicalProjectKeyFromSettings( export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: + | Pick + | null + | undefined, options?: { groupingMode?: SidebarProjectGroupingMode; }, @@ -151,8 +163,8 @@ export function deriveLogicalProjectKeyFromRef( } export function deriveProjectGroupLabel(input: { - representative: Pick; - members: ReadonlyArray>; + representative: Pick; + members: ReadonlyArray>; }): string { const sharedDisplayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.displayName), @@ -168,5 +180,5 @@ export function deriveProjectGroupLabel(input: { return sharedRepositoryNames[0]!; } - return input.representative.name; + return input.representative.title; } diff --git a/apps/web/src/modelPickerOpenState.ts b/apps/web/src/modelPickerOpenState.ts deleted file mode 100644 index 5a4993c16e7..00000000000 --- a/apps/web/src/modelPickerOpenState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { create } from "zustand"; - -const useModelPickerOpenStore = create<{ - open: boolean; - setOpen: (open: boolean) => void; -}>((set) => ({ - open: false, - setOpen: (open) => set((current) => (current.open === open ? current : { open })), -})); - -export function useModelPickerOpen(): boolean { - return useModelPickerOpenStore((store) => store.open); -} - -export function setModelPickerOpen(open: boolean): void { - useModelPickerOpenStore.getState().setOpen(open); -} diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts new file mode 100644 index 00000000000..85145ac76cf --- /dev/null +++ b/apps/web/src/modelPickerVisibility.ts @@ -0,0 +1,13 @@ +const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; + +/** + * Model-picker visibility is already represented by the mounted popover. + * Shortcut arbitration reads that source directly instead of mirroring it in + * a second React or external store. + */ +export function isModelPickerOpen(): boolean { + return ( + typeof document !== "undefined" && + document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null + ); +} diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index c0d104ac517..61c6006a8f5 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -6,6 +6,7 @@ import * as Tracer from "effect/Tracer"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -78,8 +79,8 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise + runtime.runPromiseExit( Scope.provide(scope)( OtlpTracer.make({ url: otlpTracesUrl, @@ -87,26 +88,28 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise undefined) - .finally(() => { - runtime.dispose(); - }); + await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); + runtime.dispose(); } function formatError(error: unknown): string { diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index 8b7c4b1a1dc..014d220860d 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -1,55 +1,20 @@ -import type { - DiscoveredLocalServer, - EnvironmentApi, - EnvironmentId, - ThreadId, -} from "@t3tools/contracts"; +import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { create } from "zustand"; -const EMPTY_PORTS: ReadonlyArray = Object.freeze([]); - -interface PortDiscoveryState { - readonly byEnvironment: Record>; - setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray) => void; - clearEnvironment: (environmentId: EnvironmentId) => void; - reset: () => void; -} +import { previewEnvironment } from "./state/preview"; +import { useEnvironmentQuery } from "./state/query"; -export const usePortDiscoveryStore = create((set) => ({ - byEnvironment: {}, - setPorts: (environmentId, ports) => - set((state) => ({ - byEnvironment: { - ...state.byEnvironment, - [environmentId]: ports, - }, - })), - clearEnvironment: (environmentId) => - set((state) => { - if (!(environmentId in state.byEnvironment)) return state; - const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; - return { byEnvironment }; - }), - reset: () => set({ byEnvironment: {} }), -})); - -export function subscribePortDiscovery(input: { - readonly environmentId: EnvironmentId; - readonly previewApi: Pick; -}): () => void { - usePortDiscoveryStore.getState().clearEnvironment(input.environmentId); - return input.previewApi.subscribePorts((snapshot) => { - usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers); - }); -} +const EMPTY_PORTS: ReadonlyArray = Object.freeze([]); export function useDiscoveredPorts( environmentId: EnvironmentId | null, ): ReadonlyArray { - return usePortDiscoveryStore( - (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS, + const query = useEnvironmentQuery( + environmentId === null + ? null + : previewEnvironment.discoveredServers({ environmentId, input: {} }), ); + return query.data?.servers ?? EMPTY_PORTS; } export function useThreadDiscoveredPorts(input: { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index b2df246f246..458bfb5e5a6 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -1,11 +1,24 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; +import { + __testing, + applyPreviewDesktopState, + applyPreviewServerEvent, + applyPreviewServerSnapshot, + previewStateAtom, + readThreadPreviewState, + rememberPreviewUrl, + removePreviewSession, + removePreviewThread, + resetPreviewStateForTests, + setActivePreviewTab, +} from "./previewStateStore"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); +const otherRef = scopeThreadRef(environmentId, ThreadId.make("thread-2")); const makeSnapshot = (overrides: Partial = {}): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -18,20 +31,31 @@ const makeSnapshot = (overrides: Partial = {}): PreviewS }); beforeEach(() => { - usePreviewStateStore.setState({ byThreadKey: {} }); + resetPreviewStateForTests(); }); describe("previewStateStore (single-tab)", () => { + it("keeps independent state atoms for each thread", () => { + expect(previewStateAtom(scopedThreadKey(ref))).toBe(previewStateAtom(scopedThreadKey(ref))); + expect(previewStateAtom(scopedThreadKey(ref))).not.toBe( + previewStateAtom(scopedThreadKey(otherRef)), + ); + + applyPreviewServerSnapshot(ref, makeSnapshot()); + expect(readThreadPreviewState(ref).snapshot?.tabId).toBe("tab_a"); + expect(readThreadPreviewState(otherRef)).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); + }); + it("opened event seeds the snapshot and remembers the URL", () => { const snapshot = makeSnapshot(); - usePreviewStateStore.getState().applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(snapshot.tabId); expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); }); @@ -39,36 +63,34 @@ describe("previewStateStore (single-tab)", () => { it("a second `opened` for a different tab replaces the rendered snapshot", () => { const a = makeSnapshot({ tabId: "tab_a" }); const b = makeSnapshot({ tabId: "tab_b" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: a.tabId, createdAt: a.updatedAt, snapshot: a, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: b.tabId, createdAt: b.updatedAt, snapshot: b, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(b.tabId); }); it("navigated event updates the snapshot URL", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "navigated", threadId: "thread-1", tabId: snapshot.tabId, @@ -78,7 +100,7 @@ describe("previewStateStore (single-tab)", () => { navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, }, }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("Success"); if (state.snapshot?.navStatus._tag === "Success") { expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); @@ -87,15 +109,14 @@ describe("previewStateStore (single-tab)", () => { it("failed event flips the snapshot to LoadFailed when tabId matches", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "failed", threadId: "thread-1", tabId: snapshot.tabId, @@ -105,21 +126,20 @@ describe("previewStateStore (single-tab)", () => { code: -105, description: "ERR_NAME_NOT_RESOLVED", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); }); it("failed event for a non-active tab is ignored", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "failed", threadId: "thread-1", tabId: "tab_b", @@ -129,27 +149,26 @@ describe("previewStateStore (single-tab)", () => { code: -105, description: "ERR_NAME_NOT_RESOLVED", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus._tag).toBe("Loading"); }); it("closed event clears snapshot but retains recently-seen URLs", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: snapshot.tabId, createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot).toBeNull(); expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); }); @@ -160,13 +179,12 @@ describe("previewStateStore (single-tab)", () => { tabId: "tab_b", updatedAt: "2026-01-01T00:00:01.000Z", }); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, first); - store.applyServerSnapshot(ref, second); + applyPreviewServerSnapshot(ref, first); + applyPreviewServerSnapshot(ref, second); - store.removeSession(ref, second.tabId); + removePreviewSession(ref, second.tabId); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(Object.keys(state.sessions)).toEqual([first.tabId]); expect(state.activeTabId).toBe(first.tabId); expect(state.snapshot?.tabId).toBe(first.tabId); @@ -174,60 +192,57 @@ describe("previewStateStore (single-tab)", () => { it("treats a late server close event after optimistic removal as a no-op", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.removeSession(ref, snapshot.tabId); + applyPreviewServerSnapshot(ref, snapshot); + removePreviewSession(ref, snapshot.tabId); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: snapshot.tabId, createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.sessions).toEqual({}); expect(state.snapshot).toBeNull(); }); it("closed event for a different tab is a no-op", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "closed", threadId: "thread-1", tabId: "tab_b", createdAt: "2026-01-01T00:00:01.000Z", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.tabId).toBe(snapshot.tabId); }); it("desktopOverlay updates independently of snapshot", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerEvent(ref, { + applyPreviewServerEvent(ref, { type: "opened", threadId: "thread-1", tabId: snapshot.tabId, createdAt: snapshot.updatedAt, snapshot, }); - store.applyDesktopState(ref, snapshot.tabId, { + applyPreviewDesktopState(ref, snapshot.tabId, { canGoBack: true, canGoForward: false, loading: false, zoomFactor: 1, controller: "none", }); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.desktopOverlay?.canGoBack).toBe(true); expect(state.snapshot?.canGoBack).toBe(false); }); @@ -235,19 +250,18 @@ describe("previewStateStore (single-tab)", () => { it("retains multiple tabs and switches active desktop state", () => { const first = makeSnapshot(); const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, first); - store.applyServerSnapshot(ref, second); - store.applyDesktopState(ref, first.tabId, { + applyPreviewServerSnapshot(ref, first); + applyPreviewServerSnapshot(ref, second); + applyPreviewDesktopState(ref, first.tabId, { canGoBack: true, canGoForward: false, loading: false, zoomFactor: 1, controller: "none", }); - store.setActiveTab(ref, first.tabId); + setActivePreviewTab(ref, first.tabId); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); expect(state.snapshot?.tabId).toBe(first.tabId); expect(state.desktopOverlay?.canGoBack).toBe(true); @@ -255,23 +269,21 @@ describe("previewStateStore (single-tab)", () => { it("applyServerSnapshot null clears snapshot for a thread that had one", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.applyServerSnapshot(ref, null); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + applyPreviewServerSnapshot(ref, snapshot); + applyPreviewServerSnapshot(ref, null); + const state = readThreadPreviewState(ref); expect(state.snapshot).toBeNull(); }); it("does not replace a streamed snapshot with older SWR data", () => { - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot( + applyPreviewServerSnapshot( ref, makeSnapshot({ navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, updatedAt: "2026-01-01T00:00:02.000Z", }), ); - store.applyServerSnapshot( + applyPreviewServerSnapshot( ref, makeSnapshot({ navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, @@ -279,7 +291,7 @@ describe("previewStateStore (single-tab)", () => { }), ); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.snapshot?.navStatus).toEqual({ _tag: "Success", url: "http://localhost:5173/new", @@ -288,11 +300,10 @@ describe("previewStateStore (single-tab)", () => { }); it("rememberUrl dedupes and caps at limit", () => { - const store = usePreviewStateStore.getState(); for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { - store.rememberUrl(ref, `http://localhost:${5000 + i}/`); + rememberPreviewUrl(ref, `http://localhost:${5000 + i}/`); } - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + const state = readThreadPreviewState(ref); expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); expect(state.recentlySeenUrls[0]).toBe( `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, @@ -301,10 +312,9 @@ describe("previewStateStore (single-tab)", () => { it("removeThread strips the entry", () => { const snapshot = makeSnapshot(); - const store = usePreviewStateStore.getState(); - store.applyServerSnapshot(ref, snapshot); - store.removeThread(ref); - const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + applyPreviewServerSnapshot(ref, snapshot); + removePreviewThread(ref); + const state = readThreadPreviewState(ref); expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index ab99ce63bb8..572d19750a6 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -1,25 +1,21 @@ /** * Per-thread preview UI state. * - * Single-tab model: one snapshot per thread, mirrored two ways: - * - `snapshot` is the server-authoritative URL/title/load-status, replayed - * on WS reconnect so the panel survives backend restarts. - * - `desktopOverlay` is low-latency state from the local - * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's - * button enablement. - * - * The schema-level `tabId` exists because the server still keys sessions by - * `(threadId, tabId)`; the client just always tracks one and ignores the rest. + * Each thread owns an independent atom. Most consumers read exactly one + * thread; the desktop browser host uses the aggregate session atom because it + * is the one place that must enumerate every live preview tab. */ -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type PreviewEvent, type PreviewSessionSnapshot, type ScopedThreadRef, } from "@t3tools/contracts"; -import { create } from "zustand"; +import { Atom } from "effect/unstable/reactivity"; import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; +import { appAtomRegistry } from "./rpc/atomRegistry"; export interface DesktopPreviewOverlay { canGoBack: boolean; @@ -33,10 +29,8 @@ export interface ThreadPreviewState { snapshot: PreviewSessionSnapshot | null; sessions: Record; activeTabId: string | null; - /** Bridge state takes precedence over `snapshot` for nav button enablement. */ desktopOverlay: DesktopPreviewOverlay | null; desktopByTabId: Record; - /** Recently-visited URLs surfaced in the empty state. */ recentlySeenUrls: string[]; } @@ -49,55 +43,66 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ recentlySeenUrls: [] as string[], }); -const revisionByThreadKey = new Map(); +const emptyPreviewStateAtom = Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( + Atom.withLabel("preview:empty-thread"), +); -const bumpPreviewStateRevision = (threadKey: string): void => { - revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); -}; +export const previewStateAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`preview:thread:${threadKey}`), + ), +); -export function readPreviewStateRevision(ref: ScopedThreadRef): number { - return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; +// Only the Electron browser host needs a cross-thread view. Keep that index +// separate so thread-local readers never subscribe to unrelated previews. +interface ActivePreviewThreadIndex { + readonly keys: ReadonlySet; } -export interface PreviewStateStoreState { - byThreadKey: Record; - applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; - applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; - applyDesktopState: ( - ref: ScopedThreadRef, - tabId: string, - overlay: DesktopPreviewOverlay | null, - ) => void; - removeSession: (ref: ScopedThreadRef, tabId: string) => void; - setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; - rememberUrl: (ref: ScopedThreadRef, url: string) => void; - removeThread: (ref: ScopedThreadRef) => void; -} +const activePreviewThreadKeysAtom = Atom.make({ + keys: new Set(), +}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys")); -const ensureState = ( - byThreadKey: Record, - threadKey: string, -): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; +const activePreviewSessionsAtom = Atom.make((get) => { + const byThreadKey: Record = {}; + for (const threadKey of get(activePreviewThreadKeysAtom).keys) { + const state = get(previewStateAtom(threadKey)); + if (Object.keys(state.sessions).length > 0) { + byThreadKey[threadKey] = state; + } + } + return byThreadKey; +}).pipe(Atom.withLabel("preview:active-sessions")); -const updateThread = ( - state: PreviewStateStoreState, - threadKey: string, - updater: (current: ThreadPreviewState) => ThreadPreviewState, -): PreviewStateStoreState["byThreadKey"] => { - const current = ensureState(state.byThreadKey, threadKey); - const next = updater(current); - if (next === current) return state.byThreadKey; - return { ...state.byThreadKey, [threadKey]: next }; -}; +const changedPreviewThreadKeys = new Set(); -const removeThreadKey = ( - byThreadKey: Record, - threadKey: string, -): Record => { - if (!(threadKey in byThreadKey)) return byThreadKey; - const { [threadKey]: _removed, ...rest } = byThreadKey; - return rest; -}; +function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void { + const active = Object.keys(state.sessions).length > 0; + appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => { + if (current.keys.has(threadKey) === active) return current; + const next = new Set(current.keys); + if (active) next.add(threadKey); + else next.delete(threadKey); + return { keys: next }; + }); +} + +function updateThreadPreviewState( + ref: ScopedThreadRef, + update: (current: ThreadPreviewState) => ThreadPreviewState, +): void { + const threadKey = scopedThreadKey(ref); + const atom = previewStateAtom(threadKey); + let nextState = appAtomRegistry.get(atom); + const changed = appAtomRegistry.modify(atom, (current) => { + nextState = update(current); + return [nextState !== current, nextState]; + }); + if (!changed) return; + changedPreviewThreadKeys.add(threadKey); + syncActivePreviewThread(threadKey, nextState); +} const dedupeRecentUrls = (existing: string[], url: string): string[] => { const next = [url, ...existing.filter((entry) => entry !== url)]; @@ -125,164 +130,161 @@ const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPrevie }; }; -export const usePreviewStateStore = create()((set) => ({ - byThreadKey: {}, - applyServerEvent: (ref, event) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - let nextByThread = state.byThreadKey; - switch (event.type) { - case "opened": - case "navigated": - nextByThread = updateThread(state, threadKey, (current) => { - const snapshot = event.snapshot; - const recentlySeenUrls = - snapshot.navStatus._tag === "Idle" - ? current.recentlySeenUrls - : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); - const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; - const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; - const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; - return { - ...current, - sessions, - activeTabId: activeTabId ?? snapshot.tabId, - snapshot: activeSnapshot, - desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, - recentlySeenUrls, - }; - }); - break; - case "failed": - nextByThread = updateThread(state, threadKey, (current) => { - const existing = current.sessions[event.tabId]; - if (!existing) return current; - const failedSnapshot = { - ...existing, - navStatus: { - _tag: "LoadFailed" as const, - url: event.url, - title: event.title, - code: event.code, - description: event.description, - }, - updatedAt: event.createdAt, - }; - const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; - return { - ...current, - sessions, - snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, - }; - }); - break; - case "closed": - nextByThread = updateThread(state, threadKey, (current) => - removeSession(current, event.tabId), - ); - break; - } - return { byThreadKey: nextByThread }; - }), - applyServerSnapshot: (ref, snapshot) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - const nextByThread = updateThread(state, threadKey, (current) => { - if (!snapshot && current.snapshot === null) return current; - if (!snapshot) { - return { - ...current, - snapshot: null, - sessions: {}, - activeTabId: null, - desktopOverlay: null, - desktopByTabId: {}, - }; - } - const existing = current.sessions[snapshot.tabId]; - if (existing && existing.updatedAt > snapshot.updatedAt) { - return current; - } +export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState { + const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom; + return useAtomValue(atom); +} + +export function useActivePreviewSessions(): Record { + return useAtomValue(activePreviewSessionsAtom); +} + +export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState { + return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); +} + +export function subscribeThreadPreviewState( + ref: ScopedThreadRef, + listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, +): () => void { + const atom = previewStateAtom(scopedThreadKey(ref)); + let previous = appAtomRegistry.get(atom); + return appAtomRegistry.subscribe(atom, (state) => { + const prior = previous; + previous = state; + listener(state, prior); + }); +} + +export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { + updateThreadPreviewState(ref, (current) => { + switch (event.type) { + case "opened": + case "navigated": { + const snapshot = event.snapshot; const recentlySeenUrls = - snapshot && snapshot.navStatus._tag !== "Idle" - ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) - : current.recentlySeenUrls; + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; return { ...current, - snapshot, - sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, - activeTabId: snapshot.tabId, - desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, recentlySeenUrls, }; - }); - return { byThreadKey: nextByThread }; - }), - applyDesktopState: (ref, tabId, overlay) => - set((state) => { - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => { - const desktopByTabId = { ...current.desktopByTabId }; - if (overlay) desktopByTabId[tabId] = overlay; - else delete desktopByTabId[tabId]; - return { - ...current, - desktopByTabId, - desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + } + case "failed": { + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, }; - }); - return { byThreadKey: nextByThread }; - }), - removeSession: (ref, tabId) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - return { - byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), - }; - }), - setActiveTab: (ref, tabId) => - set((state) => { - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => { - const snapshot = current.sessions[tabId]; - if (!snapshot || current.activeTabId === tabId) return current; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; return { ...current, - activeTabId: tabId, - snapshot, - desktopOverlay: current.desktopByTabId[tabId] ?? null, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, }; - }); - return { byThreadKey: nextByThread }; - }), - rememberUrl: (ref, url) => - set((state) => { - if (url.trim().length === 0) return state; - const threadKey = scopedThreadKey(ref); - const nextByThread = updateThread(state, threadKey, (current) => ({ + } + case "closed": + return removeSession(current, event.tabId); + } + }); +} + +export function applyPreviewServerSnapshot( + ref: ScopedThreadRef, + snapshot: PreviewSessionSnapshot | null, +): void { + updateThreadPreviewState(ref, (current) => { + if (!snapshot && current.snapshot === null) return current; + if (!snapshot) { + return { ...current, - recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), - })); - return { byThreadKey: nextByThread }; - }), - removeThread: (ref) => - set((state) => { - const threadKey = scopedThreadKey(ref); - bumpPreviewStateRevision(threadKey); - if (!(threadKey in state.byThreadKey)) return state; - return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; - }), -})); + snapshot: null, + sessions: {}, + activeTabId: null, + desktopOverlay: null, + desktopByTabId: {}, + }; + } + const existing = current.sessions[snapshot.tabId]; + if (existing && existing.updatedAt > snapshot.updatedAt) return current; + const recentlySeenUrls = + snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); +} + +export function applyPreviewDesktopState( + ref: ScopedThreadRef, + tabId: string, + overlay: DesktopPreviewOverlay | null, +): void { + updateThreadPreviewState(ref, (current) => { + const desktopByTabId = { ...current.desktopByTabId }; + if (overlay) desktopByTabId[tabId] = overlay; + else delete desktopByTabId[tabId]; + return { + ...current, + desktopByTabId, + desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + }; + }); +} -export function selectThreadPreviewState( - byThreadKey: Record, - ref: ScopedThreadRef | null | undefined, -): ThreadPreviewState { - if (!ref) return EMPTY_THREAD_PREVIEW_STATE; - return ensureState(byThreadKey, scopedThreadKey(ref)); +export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void { + updateThreadPreviewState(ref, (current) => removeSession(current, tabId)); +} + +export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { + updateThreadPreviewState(ref, (current) => { + const snapshot = current.sessions[tabId]; + if (!snapshot || current.activeTabId === tabId) return current; + return { + ...current, + activeTabId: tabId, + snapshot, + desktopOverlay: current.desktopByTabId[tabId] ?? null, + }; + }); +} + +export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { + if (url.trim().length === 0) return; + updateThreadPreviewState(ref, (current) => ({ + ...current, + recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), + })); +} + +export function removePreviewThread(ref: ScopedThreadRef): void { + const threadKey = scopedThreadKey(ref); + appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); + syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); + changedPreviewThreadKeys.delete(threadKey); } export function isPreviewSupportedInRuntime(): boolean { @@ -290,6 +292,14 @@ export function isPreviewSupportedInRuntime(): boolean { return Boolean(window.desktopBridge?.preview); } +export function resetPreviewStateForTests(): void { + for (const threadKey of changedPreviewThreadKeys) { + appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); + } + changedPreviewThreadKeys.clear(); + appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set() }); +} + export const __testing = { EMPTY_THREAD_PREVIEW_STATE, RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 1c02b4e8104..f0aeead1411 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -56,7 +56,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { +export function primaryProjectScript(scripts: ReadonlyArray): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 2995defc12f..3b6dcc347e4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 08f0c0cfd5f..36fa82f9ff8 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -7,7 +7,7 @@ * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/plan/files remain singleton surfaces. */ -import { scopedThreadKey } from "@t3tools/client-runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 84beaf9fc4e..f85b080f732 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,25 +1,15 @@ import { createElement } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterHistory } from "@tanstack/react-router"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory) { - const queryClient = new QueryClient(); - return createRouter({ routeTree, history, - context: { - queryClient, - }, - Wrap: ({ children }) => - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(AppAtomRegistryProvider, undefined, children), - ), + context: {}, + Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children), }); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 88283d451c3..d01518a3858 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,14 +1,14 @@ import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Outlet, - createRootRouteWithContext, + createRootRoute, type ErrorComponentProps, useLocation, useNavigate, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; @@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; -import { - SlowRpcAckToastCoordinator, - WebSocketConnectionCoordinator, - WebSocketConnectionSurface, -} from "../components/WebSocketConnectionSurface"; +import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, @@ -29,44 +25,33 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readLocalApi } from "../localApi"; import { useSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, selectProjectGroupingSettings, } from "../logicalProject"; -import { - getServerConfigUpdatedNotification, - ServerConfigUpdatedNotification, - startServerStateSync, - useServerConfig, - useServerConfigUpdatedSubscription, - useServerWelcomeSubscription, -} from "../rpc/serverState"; -import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; -import { - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - listSavedEnvironmentRecords, - waitForSavedEnvironmentRegistryHydration, - startEnvironmentConnectionService, - useSavedEnvironmentRegistryStore, -} from "../environments/runtime"; import { configureClientTracing } from "../observability/clientTracing"; -import { - ensurePrimaryEnvironmentReady, - getPrimaryKnownEnvironment, - resolveInitialServerAuthGateState, - updatePrimaryEnvironmentDescriptor, -} from "../environments/primary"; +import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { shellEnvironment } from "../state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + primaryServerConfigAtom, + primaryServerConfigEventAtom, + primaryServerWelcomeAtom, +} from "../state/server"; +import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { + createKeybindingsUpdateToastController, + type KeybindingsUpdateToastController, +} from "../components/KeybindingsUpdateToast.logic"; -export const Route = createRootRouteWithContext<{ - queryClient: QueryClient; -}>()({ +export const Route = createRootRoute({ beforeLoad: async ({ location }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { return { @@ -77,7 +62,6 @@ export const Route = createRootRouteWithContext<{ } if (isHostedStaticApp(new URL(window.location.href))) { - await waitForSavedEnvironmentRegistryHydration(); return { authGateState: { status: "hosted-static", @@ -85,10 +69,7 @@ export const Route = createRootRouteWithContext<{ }; } - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); + const authGateState = await resolveInitialServerAuthGateState(); return { authGateState, }; @@ -134,47 +115,42 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? ( - {appShell} - ) : ( - appShell - )} + {appShell} ); } function HostedStaticEnvironmentBootstrap() { - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); + const activeEnvironmentId = useActiveEnvironmentId(); useEffect(() => { - if (getPrimaryKnownEnvironment()) { + if ( + environments.some( + (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", + ) + ) { return; } - const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; - if (currentActiveEnvironmentId) { + if (activeEnvironmentId) { return; } - const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; + const firstSavedEnvironment = environments[0]; if (!firstSavedEnvironment) { return; } - useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); - }, [savedEnvironmentCount]); + setActiveEnvironmentId(firstSavedEnvironment.environmentId); + }, [activeEnvironmentId, environments]); return null; } @@ -250,18 +226,6 @@ function errorDetails(error: unknown): string { } } -function ServerStateBootstrap() { - useEffect(() => { - if (!getPrimaryKnownEnvironment()) { - return; - } - - return startServerStateSync(getPrimaryEnvironmentConnection().client.server); - }, []); - - return null; -} - function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); @@ -270,46 +234,35 @@ function AuthenticatedTracingBootstrap() { return null; } -function EnvironmentConnectionManagerBootstrap() { - const queryClient = useQueryClient(); - - useEffect(() => { - return startEnvironmentConnectionService(queryClient); - }, [queryClient]); - - return null; -} - function EventRouter() { - const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const primaryEnvironment = usePrimaryEnvironment(); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); - const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); - const lastKeybindingsSuccessToastAtRef = useRef(0); - const disposedRef = useRef(false); - const serverConfig = useServerConfig(); + const handledConfigEventRef = useRef(serverConfigEvent); + const [keybindingsToastController] = useState(() => + createKeybindingsUpdateToastController({}), + ); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; - updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); void (async () => { - await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); - if (disposedRef.current) { - return; - } - if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - const bootstrapEnvironmentState = - useStore.getState().environmentStateById[payload.environment.environmentId]; - const bootstrapProject = - bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProject = readProject( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), + ); const bootstrapProjectKey = (bootstrapProject ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) @@ -340,91 +293,84 @@ function EventRouter() { })().catch(() => undefined); }); - const handleServerConfigUpdated = useEffectEvent( - (notification: ServerConfigUpdatedNotification | null) => { - if (!notification) return; - - const { id, payload, source } = notification; - if (id <= seenServerConfigUpdateIdRef.current) { - return; - } - seenServerConfigUpdateIdRef.current = id; - if (source !== "keybindingsUpdated") { - return; - } + const handleServerConfigUpdated = useEffectEvent(() => { + const decision = keybindingsToastController.handle(serverConfigEvent); + if (!decision) { + return; + } - const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (!issue) { - const now = Date.now(); - if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { - return; - } - lastKeybindingsSuccessToastAtRef.current = now; - toastManager.add({ - type: "success", - title: "Keybindings updated", - description: "Keybindings configuration reloaded successfully.", - }); - return; - } + if (decision._tag === "Success") { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionVariant: "outline", - actionProps: { - children: "Open keybindings.json", - onClick: () => { - const api = readLocalApi(); - if (!api) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Invalid keybindings configuration", + description: decision.message, + actionVariant: "outline", + actionProps: { + children: "Open keybindings.json", + onClick: () => { + if (!serverConfig || !primaryEnvironment) { + return; + } + + const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); + if (!editor) { + return; + } + void (async () => { + const result = await openInEditor({ + environmentId: primaryEnvironment.environmentId, + input: { + cwd: serverConfig.keybindingsConfigPath, + editor, + }, + }); + if (result._tag === "Success") { return; } - - void Promise.resolve(serverConfig ?? api.server.getConfig()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }), - ); - }); - }, + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }), + ); + })(); }, - }), - ); - }, - ); + }, + }), + ); + }); useEffect(() => { if (!serverConfig) { return; } - updatePrimaryEnvironmentDescriptor(serverConfig.environment); setActiveEnvironmentId(serverConfig.environment.environmentId); - }, [serverConfig, setActiveEnvironmentId]); + }, [serverConfig]); useEffect(() => { - disposedRef.current = false; - return () => { - disposedRef.current = true; - }; - }, []); + handleWelcome(serverWelcome); + }, [serverWelcome]); - useServerWelcomeSubscription(handleWelcome); - useServerConfigUpdatedSubscription(handleServerConfigUpdated); + useEffect(() => { + if (serverConfigEvent === null || handledConfigEventRef.current === serverConfigEvent) { + return; + } + handledConfigEventRef.current = serverConfigEvent; + handleServerConfigUpdated(); + }, [serverConfigEvent]); return null; } diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 13b6f9a5d59..5640487b31b 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,28 +1,30 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; -import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; +import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ select: (params) => resolveThreadRouteRef(params), }); - const bootstrapComplete = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, - ); - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); - const environmentHasServerThreads = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, + const shell = useEnvironmentQuery( + threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), ); + const serverThreadShell = useThreadShell(threadRef); + const serverThreadDetail = useThreadDetail(threadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const threadExists = serverThreadShell !== null || serverThreadDetail !== null; + const environmentHasServerThreads = environmentThreadRefs.length > 0; const draftThreadExists = useComposerDraftStore((store) => threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, ); @@ -30,26 +32,35 @@ function ChatThreadRouteView() { threadRef ? store.getDraftThreadByRef(threadRef) : null, ); const environmentHasDraftThreads = useComposerDraftStore((store) => { - if (!threadRef) return false; + if (!threadRef) { + return false; + } return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; - const serverThreadStarted = threadHasStarted(serverThread); + const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; useEffect(() => { - if (!threadRef || !bootstrapComplete) return; + if (!threadRef || !bootstrapComplete) { + return; + } + if (!routeThreadExists && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); useEffect(() => { - if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return; + if (!threadRef || !serverThreadStarted || !draftThread) { + return; + } finalizePromotedDraftThreadByRef(threadRef); - }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); + }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) return null; + if (!threadRef || !bootstrapComplete || !routeThreadExists) { + return null; + } return ( diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 77b9f18f0d7..dd152ce1a9d 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,39 +1,40 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; -import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + DraftId, + markPromotedDraftThreadByRef, + useComposerDraftStore, +} from "../composerDraftStore"; import { SidebarInset } from "../components/ui/sidebar"; -import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; -import { useStore } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; +import { useThread, useThreadRefs } from "../state/entities"; function DraftChatThreadRouteView() { const navigate = useNavigate(); const { draftId: rawDraftId } = Route.useParams(); const draftId = DraftId.make(rawDraftId); const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); - const serverThread = useStore( - useMemo( - () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), - [draftSession?.threadId], - ), - ); + const threadRefs = useThreadRefs(); + const inferredThreadRef = draftSession + ? (threadRefs.find( + (ref) => + ref.environmentId === draftSession.environmentId && + ref.threadId === draftSession.threadId, + ) ?? null) + : null; + const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; + const serverThread = useThread(serverThreadRef); const serverThreadStarted = threadHasStarted(serverThread); - const canonicalThreadRef = useMemo( - () => - draftSession?.promotedTo - ? serverThreadStarted - ? draftSession.promotedTo - : null - : serverThread - ? { - environmentId: serverThread.environmentId, - threadId: serverThread.id, - } - : null, - [draftSession?.promotedTo, serverThread, serverThreadStarted], - ); + const canonicalThreadRef = serverThreadStarted ? serverThreadRef : null; + + useEffect(() => { + if (!inferredThreadRef || draftSession?.promotedTo) { + return; + } + markPromotedDraftThreadByRef(inferredThreadRef); + }, [draftSession?.promotedTo, inferredThreadRef]); useEffect(() => { if (!canonicalThreadRef) { diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 896f66b3e93..7be0f50414e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -5,17 +5,15 @@ import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; +import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); - if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { + if (authGateState.status === "hosted-static" && environments.length === 0) { return ; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index f28c80f0128..cc24ed6090d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,7 +1,8 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; -import { useCommandPaletteStore } from "../commandPaletteStore"; +import { isCommandPaletteOpen } from "../commandPaletteContext"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -18,14 +19,14 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { useSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "~/rpc/serverState"; +import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); - const keybindings = useServerKeybindings(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -53,7 +54,7 @@ function ChatRouteGlobalShortcuts() { }, }); - if (useCommandPaletteStore.getState().open) { + if (isCommandPaletteOpen()) { return; } @@ -68,7 +69,7 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewLocalThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, @@ -83,7 +84,7 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts deleted file mode 100644 index 950ab21f57d..00000000000 --- a/apps/web/src/rpc/serverState.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - ProjectId, - ThreadId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerLifecycleStreamEvent, - type ServerProvider, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getServerConfig, - getServerKeybindings, - onProvidersUpdated, - onServerConfigUpdated, - onWelcome, - resetServerStateForTests, - startServerStateSync, -} from "./serverState"; - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -function createDeferredPromise() { - let resolve!: (value: T) => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { promise, resolve }; -} - -const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); -const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); - -const defaultProviders: ReadonlyArray = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const serverApi = { - getConfig: vi.fn<() => Promise>(), - subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => - registerListener(configListeners, listener), - ), - subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => - registerListener(lifecycleListeners, listener), - ), -}; - -function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { - for (const listener of lifecycleListeners) { - listener(event); - } -} - -function emitServerConfigEvent(event: ServerConfigStreamEvent) { - for (const listener of configListeners) { - listener(event); - } -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -beforeEach(() => { - vi.clearAllMocks(); - lifecycleListeners.clear(); - configListeners.clear(); - resetServerStateForTests(); -}); - -afterEach(() => { - resetServerStateForTests(); -}); - -describe("serverState", () => { - it("uses default keybindings before a server config snapshot is available", () => { - expect(getServerConfig()).toBeNull(); - expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS); - }); - - it("bootstraps the server config snapshot and replays it to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - - const configListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribe = onServerConfigUpdated(configListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - expect(serverApi.subscribeConfig).toHaveBeenCalledOnce(); - expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce(); - expect(serverApi.getConfig).toHaveBeenCalledOnce(); - expect(configListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - const lateListener = vi.fn(); - const unsubscribeLate = onServerConfigUpdated(lateListener); - expect(lateListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => { - const deferred = createDeferredPromise(); - serverApi.getConfig.mockReturnValueOnce(deferred.promise); - const stop = startServerStateSync(serverApi); - - const streamedConfig: ServerConfig = { - ...baseServerConfig, - cwd: "/tmp/from-stream", - }; - - emitServerConfigEvent({ - version: 1, - type: "snapshot", - config: streamedConfig, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual(streamedConfig); - }); - - deferred.resolve(baseServerConfig); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(getServerConfig()).toEqual(streamedConfig); - stop(); - }); - - it("replays welcome events to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const stop = startServerStateSync(serverApi); - - const listener = vi.fn(); - const unsubscribe = onWelcome(listener); - - emitLifecycleEvent({ - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }, - }); - - expect(listener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - const lateListener = vi.fn(); - const unsubscribeLate = onWelcome(lateListener); - expect(lateListener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("merges provider, settings, and keybinding updates into the cached config", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const configListener = vi.fn(); - const providersListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribeConfig = onServerConfigUpdated(configListener); - const unsubscribeProviders = onProvidersUpdated(providersListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - const nextProviders: ReadonlyArray = [ - { - ...defaultProviders[0]!, - status: "warning", - checkedAt: "2026-01-02T00:00:00.000Z", - message: "rate limited", - }, - ]; - - const nextKeybindings = [ - { - command: "commandPalette.toggle", - shortcut: { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - }, - ] as const; - - emitServerConfigEvent({ - version: 1, - type: "keybindingsUpdated", - payload: { - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - }, - }); - emitServerConfigEvent({ - version: 1, - type: "providerStatuses", - payload: { - providers: nextProviders, - }, - }); - emitServerConfigEvent({ - version: 1, - type: "settingsUpdated", - payload: { - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual({ - ...baseServerConfig, - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }); - }); - - expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); - expect(configListener).toHaveBeenNthCalledWith( - 2, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "keybindingsUpdated", - ); - expect(configListener).toHaveBeenNthCalledWith( - 3, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "providerStatuses", - ); - expect(configListener).toHaveBeenLastCalledWith( - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - "settingsUpdated", - ); - - unsubscribeProviders(); - unsubscribeConfig(); - stop(); - }); -}); diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts deleted file mode 100644 index 64bc2d80e5a..00000000000 --- a/apps/web/src/rpc/serverState.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; -import { - DEFAULT_SERVER_SETTINGS, - type EditorId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerConfigUpdatedPayload, - type ServerLifecycleWelcomePayload, - type ServerProvider, - type ServerProviderUpdatedPayload, - type ServerSettings, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { Atom } from "effect/unstable/reactivity"; -import { useCallback, useRef } from "react"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; - -export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; - -export interface ServerConfigUpdatedNotification { - readonly id: number; - readonly payload: ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; -} - -type ServerStateClient = Pick< - WsRpcClient["server"], - "getConfig" | "subscribeConfig" | "subscribeLifecycle" ->; - -function makeStateAtom(label: string, initialValue: A) { - return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); -} - -function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { - return { - issues: config.issues, - providers: config.providers, - settings: config.settings, - }; -} - -const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - -const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray => - config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; -const selectKeybindings = (config: ServerConfig | null) => - config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; -const selectKeybindingsConfigPath = (config: ServerConfig | null) => - config?.keybindingsConfigPath ?? null; -const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; -const selectProviders = (config: ServerConfig | null) => - config?.providers ?? EMPTY_SERVER_PROVIDERS; -const selectSettings = (config: ServerConfig | null): ServerSettings => - config?.settings ?? DEFAULT_SERVER_SETTINGS; - -export const welcomeAtom = makeStateAtom( - "server-welcome", - null, -); -export const serverConfigAtom = makeStateAtom("server-config", null); -export const serverConfigUpdatedAtom = makeStateAtom( - "server-config-updated", - null, -); -export const providersUpdatedAtom = makeStateAtom( - "server-providers-updated", - null, -); - -export function getServerConfig(): ServerConfig | null { - return appAtomRegistry.get(serverConfigAtom); -} - -export function getServerKeybindings(): ServerConfig["keybindings"] { - return selectKeybindings(getServerConfig()); -} - -export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { - return appAtomRegistry.get(serverConfigUpdatedAtom); -} - -export function setServerConfigSnapshot(config: ServerConfig): void { - resolveServerConfig(config); - emitProvidersUpdated({ providers: config.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); -} - -export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { - switch (event.type) { - case "snapshot": { - setServerConfigSnapshot(event.config); - return; - } - case "keybindingsUpdated": { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "providerStatuses": { - applyProvidersUpdated(event.payload); - return; - } - case "settingsUpdated": { - applySettingsUpdated(event.payload.settings); - return; - } - } -} - -export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - const latestServerConfig = getServerConfig(); - emitProvidersUpdated(payload); - - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - providers: payload.providers, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); -} - -export function applySettingsUpdated(settings: ServerSettings): void { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - settings, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); -} - -export function emitWelcome(payload: ServerLifecycleWelcomePayload): void { - appAtomRegistry.set(welcomeAtom, payload); -} - -export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void { - return subscribeLatest(welcomeAtom, listener); -} - -export function onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, -): () => void { - return subscribeLatest(serverConfigUpdatedAtom, (notification) => { - listener(notification.payload, notification.source); - }); -} - -export function onProvidersUpdated( - listener: (payload: ServerProviderUpdatedPayload) => void, -): () => void { - return subscribeLatest(providersUpdatedAtom, listener); -} - -export function startServerStateSync(client: ServerStateClient): () => void { - let disposed = false; - const cleanups = [ - client.subscribeLifecycle((event) => { - if (event.type === "welcome") { - emitWelcome(event.payload); - } - }), - client.subscribeConfig((event) => { - applyServerConfigEvent(event); - }), - ]; - - if (getServerConfig() === null) { - void client - .getConfig() - .then((config) => { - if (disposed || getServerConfig() !== null) { - return; - } - setServerConfigSnapshot(config); - }) - .catch(() => undefined); - } - - return () => { - disposed = true; - for (const cleanup of cleanups) { - cleanup(); - } - }; -} - -export function resetServerStateForTests() { - resetAppAtomRegistryForTests(); - nextServerConfigUpdatedNotificationId = 1; -} - -let nextServerConfigUpdatedNotificationId = 1; - -function resolveServerConfig(config: ServerConfig): void { - appAtomRegistry.set(serverConfigAtom, config); -} - -function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - appAtomRegistry.set(providersUpdatedAtom, payload); -} - -function emitServerConfigUpdated( - payload: ServerConfigUpdatedPayload, - source: ServerConfigUpdateSource, -): void { - appAtomRegistry.set(serverConfigUpdatedAtom, { - id: nextServerConfigUpdatedNotificationId++, - payload, - source, - }); -} - -function subscribeLatest( - atom: Atom.Atom, - listener: (value: NonNullable) => void, -): () => void { - return appAtomRegistry.subscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable); - }, - { immediate: true }, - ); -} - -function useLatestAtomSubscription( - atom: Atom.Atom, - listener: (value: NonNullable) => void, -): void { - const listenerRef = useRef(listener); - listenerRef.current = listener; - - const stableListener = useCallback((value: A | null) => { - if (value === null) { - return; - } - listenerRef.current(value as NonNullable); - }, []); - - useAtomSubscribe(atom, stableListener, { immediate: true }); -} - -export function useServerConfig(): ServerConfig | null { - return useAtomValue(serverConfigAtom); -} - -export function useServerSettings(): ServerSettings { - return useAtomValue(serverConfigAtom, selectSettings); -} - -export function useServerProviders(): ReadonlyArray { - return useAtomValue(serverConfigAtom, selectProviders); -} - -export function useServerKeybindings(): ServerConfig["keybindings"] { - return useAtomValue(serverConfigAtom, selectKeybindings); -} - -export function useServerAvailableEditors(): ReadonlyArray { - return useAtomValue(serverConfigAtom, selectAvailableEditors); -} - -export function useServerKeybindingsConfigPath(): string | null { - return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); -} - -export function useServerObservability(): ServerConfig["observability"] | null { - return useAtomValue(serverConfigAtom, selectObservability); -} - -export function useServerWelcomeSubscription( - listener: (payload: ServerLifecycleWelcomePayload) => void, -): void { - useLatestAtomSubscription(welcomeAtom, listener); -} - -export function useServerConfigUpdatedSubscription( - listener: (notification: ServerConfigUpdatedNotification) => void, -): void { - useLatestAtomSubscription(serverConfigUpdatedAtom, listener); -} diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 649d06f3a70..493de5f93bd 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1,4 @@ export { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/rpc/wsConnectionState.test.ts b/apps/web/src/rpc/wsConnectionState.test.ts deleted file mode 100644 index efb4d6e62f3..00000000000 --- a/apps/web/src/rpc/wsConnectionState.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getWsConnectionStatus, - getWsReconnectDelayMsForRetry, - getWsConnectionUiState, - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, - resetWsConnectionStateForTests, - setBrowserOnlineStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "./wsConnectionState"; - -describe("wsConnectionState", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T20:30:00.000Z")); - resetWsConnectionStateForTests(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("treats a disconnected browser as offline once the websocket drops", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed({ code: 1006, reason: "offline" }); - setBrowserOnlineStatus(false); - - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); - }); - - it("stays in the initial connecting state until the first disconnect", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - hasConnected: false, - phase: "connecting", - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting"); - }); - - it("schedules the next retry after a failed websocket attempt", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - - const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); - if (firstRetryDelayMs === null) { - throw new Error("Expected an initial retry delay."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - connectionLabel: "Remote Mac", - nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }); - }); - - it("adds a version mismatch hint to websocket errors when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }); - - expect(getWsConnectionStatus()).toMatchObject({ - lastError: - "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed( - { code: 1006, reason: "socket closed" }, - { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }, - ); - - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("marks the reconnect cycle as exhausted after the final attempt fails", () => { - for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - nextRetryAt: null, - reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "exhausted", - }); - }); -}); diff --git a/apps/web/src/rpc/wsConnectionState.ts b/apps/web/src/rpc/wsConnectionState.ts deleted file mode 100644 index 9e67f461184..00000000000 --- a/apps/web/src/rpc/wsConnectionState.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime"; -import { Atom } from "effect/unstable/reactivity"; - -import { appAtomRegistry } from "./atomRegistry"; - -export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; -export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; - -export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs; -export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor; -export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs; -export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!; -export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; - -export interface WsConnectionStatus { - readonly attemptCount: number; - readonly closeCode: number | null; - readonly closeReason: string | null; - readonly connectionLabel: string | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; - readonly hasConnected: boolean; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly nextRetryAt: string | null; - readonly online: boolean; - readonly phase: "idle" | "connecting" | "connected" | "disconnected"; - readonly reconnectAttemptCount: number; - readonly reconnectMaxAttempts: number; - readonly reconnectPhase: WsReconnectPhase; - readonly socketUrl: string | null; -} - -const INITIAL_WS_CONNECTION_STATUS = Object.freeze({ - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: typeof navigator === "undefined" ? true : navigator.onLine !== false, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "idle", - socketUrl: null, -}); - -export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe( - Atom.keepAlive, - Atom.withLabel("ws-connection-status"), -); - -function isoNow() { - return new Date().toISOString(); -} - -function updateWsConnectionStatus( - updater: (current: WsConnectionStatus) => WsConnectionStatus, -): WsConnectionStatus { - const nextStatus = updater(getWsConnectionStatus()); - appAtomRegistry.set(wsConnectionStatusAtom, nextStatus); - return nextStatus; -} - -export interface WsConnectionMetadata { - readonly connectionLabel?: string | null; - readonly versionMismatchHint?: string | null; -} - -function normalizeConnectionLabel(label: string | null | undefined): string | null { - const normalized = label?.trim(); - return normalized ? normalized : null; -} - -export function getWsConnectionStatus(): WsConnectionStatus { - return appAtomRegistry.get(wsConnectionStatusAtom); -} - -export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState { - if (status.phase === "connected") { - return "connected"; - } - - if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) { - return "offline"; - } - - if (!status.hasConnected) { - return status.phase === "disconnected" ? "error" : "connecting"; - } - - return "reconnecting"; -} - -export function recordWsConnectionAttempt( - socketUrl: string, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - attemptCount: current.attemptCount + 1, - connectionLabel: connectionLabel ?? current.connectionLabel, - nextRetryAt: null, - phase: "connecting", - reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, - reconnectPhase: "attempting", - socketUrl, - })); -} - -export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - closeCode: null, - closeReason: null, - connectionLabel: connectionLabel ?? current.connectionLabel, - connectedAt: isoNow(), - disconnectedAt: null, - hasConnected: true, - nextRetryAt: null, - phase: "connected", - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -function appendHint(message: string | null | undefined, hint: string | null | undefined) { - const normalizedMessage = message?.trim(); - const normalizedHint = hint?.trim(); - if (!normalizedMessage) { - return normalizedHint ? `Hint: ${normalizedHint}` : null; - } - return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage; -} - -export function recordWsConnectionErrored( - message?: string | null, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - return updateWsConnectionStatus((current) => - applyDisconnectState(current, { - lastError: - appendHint(message, metadata?.versionMismatchHint) ?? - appendHint(current.lastError, metadata?.versionMismatchHint), - lastErrorAt: isoNow(), - }), - ); -} - -export function recordWsConnectionClosed( - details?: { - readonly code?: number; - readonly reason?: string; - }, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => - applyDisconnectState( - current, - { - closeCode: details?.code ?? current.closeCode, - closeReason: - appendHint(details?.reason, metadata?.versionMismatchHint) ?? - appendHint(current.closeReason, metadata?.versionMismatchHint), - }, - connectionLabel === null ? undefined : { connectionLabel }, - ), - ); -} - -export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - online, - })); -} - -export function resetWsReconnectBackoff(): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - nextRetryAt: null, - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -export function resetWsConnectionStateForTests(): void { - appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); -} - -export function useWsConnectionStatus(): WsConnectionStatus { - return useAtomValue(wsConnectionStatusAtom); -} - -export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { - return getReconnectDelayMs(retryIndex); -} - -function applyDisconnectState( - current: WsConnectionStatus, - updates: Partial< - Pick - >, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const disconnectedAt = current.disconnectedAt ?? isoNow(); - const nextRetryDelayMs = - current.nextRetryAt !== null || current.reconnectPhase === "exhausted" - ? null - : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); - - return { - ...current, - ...updates, - connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel, - disconnectedAt, - nextRetryAt: - nextRetryDelayMs === null - ? current.nextRetryAt - : new Date(Date.now() + nextRetryDelayMs).toISOString(), - phase: "disconnected", - reconnectPhase: - current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" - ? current.reconnectPhase - : nextRetryDelayMs === null - ? "exhausted" - : "waiting", - }; -} diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts deleted file mode 100644 index eb6fb494da2..00000000000 --- a/apps/web/src/rpc/wsTransport.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - __resetClientTracingForTests, - configureClientTracing, -} from "../observability/clientTracing"; -import { - getSlowRpcAckRequests, - resetRequestLatencyStateForTests, - setSlowRpcAckThresholdMsForTests, -} from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - resetWsConnectionStateForTests, -} from "../rpc/wsConnectionState"; -import { WsTransport } from "./wsTransport"; - -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -function createTransport(...args: ConstructorParameters): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - await __resetClientTracingForTests(); - vi.restoreAllMocks(); -}); - -describe("WsTransport (web instrumentation)", () => { - it("tracks initial connection failures for the app error state", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - phase: "connecting", - socketUrl: "ws://localhost:3020/ws", - }); - - socket.error(); - socket.close(1006, "server unavailable"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeCode: 1006, - closeReason: "server unavailable", - hasConnected: false, - lastError: "Unable to connect to the T3 server WebSocket.", - phase: "disconnected", - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); - - await transport.dispose(); - }); - - it("surfaces reconnecting state after a live socket disconnects", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1013, "try again later"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "try again later", - hasConnected: true, - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); - - await transport.dispose(); - }); - - it("composes custom lifecycle handlers with default websocket state tracking", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 2, - closeReason: "service restart", - phase: "connecting", - }); - }, 2_000); - - await transport.dispose(); - }); - - it("marks unary requests as slow until the first server ack arrives", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: requestMessage.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - expect(getSlowRpcAckRequests()).toEqual([]); - - await transport.dispose(); - }, 5_000); - - it("clears slow unary request tracking when the transport reconnects", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: firstRequest.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - void requestPromise.catch(() => undefined); - - await transport.reconnect(); - - expect(getSlowRpcAckRequests()).toEqual([]); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - secondSocket.open(); - - await transport.dispose(); - }, 5_000); - - it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { - await configureClientTracing({ - exportIntervalMs: 10, - }); - - const transport = createTransport("ws://localhost:3020"); - const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - id: string; - spanId?: string; - traceId?: string; - }; - expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); - expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), - }, - }), - ); - - await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); - await transport.dispose(); - }); -}); diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts deleted file mode 100644 index 7c3b4303f3a..00000000000 --- a/apps/web/src/rpc/wsTransport.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - WsTransport as BaseWsTransport, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolSocketUrlProvider, - type WsTransportOptions, -} from "@t3tools/client-runtime"; -import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime"; - -import { ClientTracingLive } from "../observability/clientTracing"; -import { - acknowledgeRpcRequest, - clearAllTrackedRpcRequests, - trackRpcRequestSent, -} from "./requestLatencyState"; -import { - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, -} from "./wsConnectionState"; - -function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, -) { - return createSharedWsRpcProtocolLayer(url, handlers, { - telemetryLifecycle: { - onAttempt: recordWsConnectionAttempt, - onOpen: recordWsConnectionOpened, - onError: (message) => { - clearAllTrackedRpcRequests(); - recordWsConnectionErrored(message); - }, - onClose: (details, context) => { - clearAllTrackedRpcRequests(); - if (context.intentional) { - return; - } - recordWsConnectionClosed(details); - }, - }, - requestTelemetry: { - onRequestSent: trackRpcRequestSent, - onRequestAcknowledged: acknowledgeRpcRequest, - onClearTrackedRequests: clearAllTrackedRpcRequests, - }, - }); -} - -const webWsTransportOptions = { - tracingLayer: ClientTracingLive, - createProtocolLayer: createWsRpcProtocolLayer, - onBeforeReconnect: () => clearAllTrackedRpcRequests(), -} satisfies WsTransportOptions; - -export class WsTransport extends BaseWsTransport { - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) { - super(url, lifecycleHandlers, webWsTransportOptions); - } -} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index beb40aadff9..0f12e672f66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1501,6 +1501,8 @@ describe("deriveTimelineEntries", () => { role: "assistant", text: "hello", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-23T00:00:01.000Z", streaming: false, }, ], @@ -1586,7 +1588,7 @@ describe("isLatestTurnSettled", () => { it("returns false while the same turn is still active in a running session", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }), ).toBe(false); @@ -1595,7 +1597,7 @@ describe("isLatestTurnSettled", () => { it("returns false while any turn is running to avoid stale latest-turn banners", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }), ).toBe(false); @@ -1604,8 +1606,8 @@ describe("isLatestTurnSettled", () => { it("returns true once the session is no longer running that turn", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }), ).toBe(true); }); @@ -1636,7 +1638,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }, "2026-02-27T21:11:00.000Z", @@ -1649,7 +1651,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }, "2026-02-27T21:11:00.000Z", @@ -1662,8 +1664,8 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }, "2026-02-27T21:11:00.000Z", ), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5576ebeffc1..5d5051f748e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -289,7 +289,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str } type LatestTurnTiming = Pick; -type SessionActivityState = Pick; +type SessionActivityState = Pick, "status" | "activeTurnId">; export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, @@ -298,7 +298,7 @@ export function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - if (session.orchestrationStatus === "running") return false; + if (session.status === "running") return false; return true; } @@ -307,8 +307,7 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { - const runningTurnId = - session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; + const runningTurnId = session?.status === "running" ? session.activeTurnId : null; if (runningTurnId !== null) { if (latestTurn?.turnId === runningTurnId) { return latestTurn.startedAt ?? sendStartedAt; @@ -1339,9 +1338,9 @@ function compareActivityLifecycleRank(kind: string): number { } export function deriveTimelineEntries( - messages: ChatMessage[], - proposedPlans: ProposedPlan[], - workEntries: WorkLogEntry[], + messages: ReadonlyArray, + proposedPlans: ReadonlyArray, + workEntries: ReadonlyArray, ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1367,7 +1366,7 @@ export function deriveTimelineEntries( } export function inferCheckpointTurnCountByTurnId( - summaries: TurnDiffSummary[], + summaries: ReadonlyArray, ): Record { const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); const result: Record = {}; @@ -1380,8 +1379,15 @@ export function inferCheckpointTurnCountByTurnId( } export function derivePhase(session: ThreadSession | null): SessionPhase { - if (!session || session.status === "closed") return "disconnected"; - if (session.status === "connecting") return "connecting"; + if ( + !session || + session.status === "stopped" || + session.status === "interrupted" || + session.status === "error" + ) { + return "disconnected"; + } + if (session.status === "starting") return "connecting"; if (session.status === "running") return "running"; return "ready"; } diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index c506cba472c..cb62d45bcc0 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; import { areShortcutModifierStatesEqual, - clearShortcutModifierState, - readShortcutModifierState, - setShortcutModifierState, - syncShortcutModifierStateFromKeyboardEvent, + shortcutModifierStateAfterKeyboardEvent, + type ShortcutModifierState, } from "./shortcutModifierState"; +const emptyState = (): ShortcutModifierState => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, +}); + function keyboardEventLike(type: "keydown" | "keyup", init: Partial): KeyboardEvent { return { type, @@ -36,107 +41,69 @@ describe("shortcutModifierState", () => { ).toBe(false); }); - it("preserves the current store object when modifier values do not change", () => { - clearShortcutModifierState(); - - const initialState = readShortcutModifierState(); - setShortcutModifierState({ - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }); - - expect(readShortcutModifierState()).toBe(initialState); - - setShortcutModifierState({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - const updatedState = readShortcutModifierState(); - expect(updatedState).not.toBe(initialState); - expect(updatedState).toEqual({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - - setShortcutModifierState({ - metaKey: false, - ctrlKey: true, - altKey: false, - shiftKey: false, - }); - expect(readShortcutModifierState()).toBe(updatedState); - - clearShortcutModifierState(); - const clearedState = readShortcutModifierState(); - expect(clearedState).toEqual({ - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }); - expect(clearedState).not.toBe(updatedState); - - clearShortcutModifierState(); - expect(readShortcutModifierState()).toBe(clearedState); + it("preserves the current object when modifier values do not change", () => { + const initialState = emptyState(); + const nextState = shortcutModifierStateAfterKeyboardEvent( + initialState, + keyboardEventLike("keyup", { key: "Shift" }), + ); + expect(nextState).toBe(initialState); }); it("tracks bare modifier keydown and keyup events explicitly", () => { - clearShortcutModifierState(); - - syncShortcutModifierStateFromKeyboardEvent( + let state = emptyState(); + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keydown", { key: "Meta", metaKey: false, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: true, ctrlKey: false, altKey: false, shiftKey: false, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keydown", { key: "Shift", metaKey: true, shiftKey: false, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keyup", { key: "Meta", metaKey: true, shiftKey: true, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: false, ctrlKey: false, altKey: false, shiftKey: true, }); - syncShortcutModifierStateFromKeyboardEvent( + state = shortcutModifierStateAfterKeyboardEvent( + state, keyboardEventLike("keyup", { key: "Shift", shiftKey: true, }), ); - expect(readShortcutModifierState()).toEqual({ + expect(state).toEqual({ metaKey: false, ctrlKey: false, altKey: false, diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index 370f3b0a70c..a56a1a129d0 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -1,4 +1,4 @@ -import { create } from "zustand"; +import { useEffect, useState } from "react"; export interface ShortcutModifierState { metaKey: boolean; @@ -26,24 +26,32 @@ export function areShortcutModifierStatesEqual( ); } -const useShortcutModifierStateStore = create<{ - state: ShortcutModifierState; - setState: (state: ShortcutModifierState) => void; - clear: () => void; -}>((set) => ({ - state: EMPTY_SHORTCUT_MODIFIER_STATE, - setState: (state) => - set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), - clear: () => - set((current) => - areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) - ? current - : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, - ), -})); - export function useShortcutModifierState(): ShortcutModifierState { - return useShortcutModifierStateStore((store) => store.state); + const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); + + useEffect(() => { + const onKeyboardEvent = (event: KeyboardEvent) => { + setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); + }; + const onWindowBlur = () => { + setState((current) => + areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) + ? current + : EMPTY_SHORTCUT_MODIFIER_STATE, + ); + }; + + window.addEventListener("keydown", onKeyboardEvent, true); + window.addEventListener("keyup", onKeyboardEvent, true); + window.addEventListener("blur", onWindowBlur); + return () => { + window.removeEventListener("keydown", onKeyboardEvent, true); + window.removeEventListener("keyup", onKeyboardEvent, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + + return state; } function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { @@ -64,33 +72,25 @@ function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { } } -export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { +export function shortcutModifierStateAfterKeyboardEvent( + currentState: ShortcutModifierState, + event: KeyboardEvent, +): ShortcutModifierState { const normalizedModifierKey = normalizeModifierKey(event.key); + let nextState: ShortcutModifierState; if (normalizedModifierKey) { - const currentState = useShortcutModifierStateStore.getState().state; - useShortcutModifierStateStore.getState().setState({ + nextState = { ...currentState, [normalizedModifierKey]: event.type === "keydown", - }); - return; + }; + } else { + nextState = { + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }; } - useShortcutModifierStateStore.getState().setState({ - metaKey: event.metaKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, - }); -} - -export function setShortcutModifierState(state: ShortcutModifierState): void { - useShortcutModifierStateStore.getState().setState(state); -} - -export function clearShortcutModifierState(): void { - useShortcutModifierStateStore.getState().clear(); -} - -export function readShortcutModifierState(): ShortcutModifierState { - return useShortcutModifierStateStore.getState().state; + return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState; } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8909c1bf755..c90cf51d969 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -104,7 +104,7 @@ export function buildSidebarProjectSnapshots(input: { representative, members, }) - : representative.name, + : representative.title, groupedProjectCount: members.length, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts new file mode 100644 index 00000000000..5e31beb826b --- /dev/null +++ b/apps/web/src/state/assets.ts @@ -0,0 +1,5 @@ +import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/auth.ts b/apps/web/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/web/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts new file mode 100644 index 00000000000..7af13cbbcfc --- /dev/null +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -0,0 +1,50 @@ +import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopNetworkAccessStateAtom } from "./desktopNetworkAccess"; + +const serverExposureState: DesktopServerExposureState = { + advertisedHost: "192.168.1.10", + endpointUrl: "http://192.168.1.10:37737", + mode: "network-accessible", + tailscaleServeEnabled: false, + tailscaleServePort: 443, +}; + +const advertisedEndpoints: ReadonlyArray = []; + +describe("desktopNetworkAccessState", () => { + it("retains the loaded snapshot when the settings screen remounts", async () => { + const getServerExposureState = vi.fn(async () => serverExposureState); + const getAdvertisedEndpoints = vi.fn(async () => advertisedEndpoints); + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints, + getServerExposureState, + })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some" }), + ); + }); + unmount(); + + const remount = registry.mount(atom); + const result = registry.get(atom); + expect(AsyncResult.value(result)).toEqual( + expect.objectContaining({ + _tag: "Some", + value: { advertisedEndpoints, serverExposureState }, + }), + ); + expect(getServerExposureState).toHaveBeenCalledTimes(1); + expect(getAdvertisedEndpoints).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopNetworkAccess.ts b/apps/web/src/state/desktopNetworkAccess.ts new file mode 100644 index 00000000000..150a256bc68 --- /dev/null +++ b/apps/web/src/state/desktopNetworkAccess.ts @@ -0,0 +1,79 @@ +import type { + AdvertisedEndpoint, + DesktopBridge, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +const DESKTOP_NETWORK_ACCESS_STALE_TIME_MS = 30_000; + +type DesktopNetworkAccessBridge = Pick< + DesktopBridge, + "getAdvertisedEndpoints" | "getServerExposureState" +>; + +export interface DesktopNetworkAccessSnapshot { + readonly advertisedEndpoints: ReadonlyArray; + readonly serverExposureState: DesktopServerExposureState; +} + +class DesktopNetworkAccessError extends Schema.TaggedErrorClass()( + "DesktopNetworkAccessError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopNetworkAccessStateAtom( + getBridge: () => DesktopNetworkAccessBridge | undefined, +) { + const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { + const bridge = getBridge(); + if (!bridge) { + return yield* new DesktopNetworkAccessError({ + message: "Desktop network access is unavailable.", + }); + } + return yield* Effect.tryPromise({ + try: async (): Promise => { + const [serverExposureState, advertisedEndpoints] = await Promise.all([ + bridge.getServerExposureState(), + bridge.getAdvertisedEndpoints(), + ]); + return { advertisedEndpoints, serverExposureState }; + }, + catch: (cause) => + new DesktopNetworkAccessError({ + message: + cause instanceof Error ? cause.message : "Failed to load desktop network access.", + cause, + }), + }); + }); + + return Atom.make(loadDesktopNetworkAccess()).pipe( + Atom.swr({ + staleTime: DESKTOP_NETWORK_ACCESS_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.keepAlive, + Atom.withLabel("desktop:network-access"), + ); +} + +export const desktopNetworkAccessStateAtom = createDesktopNetworkAccessStateAtom( + getDesktopNetworkAccessBridge, +); + +export function refreshDesktopNetworkAccessState(): void { + appAtomRegistry.refresh(desktopNetworkAccessStateAtom); +} diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts new file mode 100644 index 00000000000..571704a95f5 --- /dev/null +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -0,0 +1,41 @@ +import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopSshHostsStateAtom } from "./desktopSshHosts"; + +const hosts: ReadonlyArray = [ + { + alias: "devbox", + hostname: "devbox.local", + port: null, + source: "ssh-config", + username: null, + }, +]; + +describe("desktopSshHostsState", () => { + it("retains discovered hosts when the settings screen remounts", async () => { + const discoverSshHosts = vi.fn(async () => hosts); + const atom = createDesktopSshHostsStateAtom(() => ({ discoverSshHosts })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some", value: hosts }), + ); + }); + unmount(); + + const remount = registry.mount(atom); + expect(AsyncResult.value(registry.get(atom))).toEqual( + expect.objectContaining({ _tag: "Some", value: hosts }), + ); + expect(discoverSshHosts).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts new file mode 100644 index 00000000000..47b2c87e97c --- /dev/null +++ b/apps/web/src/state/desktopSshHosts.ts @@ -0,0 +1,49 @@ +import type { DesktopBridge, DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +type DesktopSshDiscoveryBridge = Pick; + +class DesktopSshDiscoveryError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopSshHostsStateAtom( + getBridge: () => DesktopSshDiscoveryBridge | undefined, +) { + const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { + const bridge = getBridge(); + if (!bridge) { + return yield* new DesktopSshDiscoveryError({ + message: "Desktop SSH host discovery is unavailable.", + }); + } + return yield* Effect.tryPromise({ + try: (): Promise> => bridge.discoverSshHosts(), + catch: (cause) => + new DesktopSshDiscoveryError({ + message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", + cause, + }), + }); + }); + + return Atom.make(discoverDesktopSshHosts()).pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.keepAlive, + Atom.withLabel("desktop:ssh-hosts"), + ); +} + +export const desktopSshHostsStateAtom = createDesktopSshHostsStateAtom( + getDesktopSshDiscoveryBridge, +); diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts new file mode 100644 index 00000000000..77409ef611d --- /dev/null +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -0,0 +1,111 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createDesktopUpdateStateAtom } from "./desktopUpdate"; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +describe("desktopUpdateStateAtom", () => { + it("loads once, retains state, and follows desktop update events", async () => { + let listener: ((state: DesktopUpdateState) => void) | undefined; + const unsubscribe = vi.fn(); + const getUpdateState = vi.fn(async () => baseState); + const onUpdateState = vi.fn((nextListener: (state: DesktopUpdateState) => void) => { + listener = nextListener; + return unsubscribe; + }); + const atom = createDesktopUpdateStateAtom(() => ({ getUpdateState, onUpdateState })); + const registry = AtomRegistry.make(); + + const unmount = registry.mount(atom); + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); + }); + + const downloadedState: DesktopUpdateState = { + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + listener?.(downloadedState); + + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); + }); + unmount(); + + const remount = registry.mount(atom); + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(downloadedState); + expect(getUpdateState).toHaveBeenCalledTimes(1); + expect(onUpdateState).toHaveBeenCalledTimes(1); + + remount(); + registry.dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("does not let a slower initial read overwrite a newer update event", async () => { + let resolveInitial: ((state: DesktopUpdateState) => void) | undefined; + let listener: ((state: DesktopUpdateState) => void) | undefined; + const atom = createDesktopUpdateStateAtom(() => ({ + getUpdateState: () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + onUpdateState: (nextListener) => { + listener = nextListener; + return () => undefined; + }, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(listener).toBeDefined()); + const newerState: DesktopUpdateState = { ...baseState, status: "checking" }; + listener?.(newerState); + resolveInitial?.(baseState); + + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(newerState); + }); + registry.dispose(); + }); + + it("keeps listening when the initial desktop state read fails", async () => { + let listener: ((state: DesktopUpdateState) => void) | undefined; + const atom = createDesktopUpdateStateAtom(() => ({ + getUpdateState: async () => Promise.reject(new Error("IPC unavailable")), + onUpdateState: (nextListener) => { + listener = nextListener; + return () => undefined; + }, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(listener).toBeDefined()); + listener?.(baseState); + await vi.waitFor(() => { + expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); + }); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts new file mode 100644 index 00000000000..d08169770c3 --- /dev/null +++ b/apps/web/src/state/desktopUpdate.ts @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { Atom } from "effect/unstable/reactivity"; + +type DesktopUpdateBridge = Pick; + +function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridge | undefined) { + const updates = Stream.callback((queue) => + Effect.gen(function* () { + const bridge = getBridge(); + if (!bridge) { + Queue.offerUnsafe(queue, null); + return yield* Effect.never; + } + + let receivedUpdate = false; + yield* Effect.acquireRelease( + Effect.sync(() => + bridge.onUpdateState((state) => { + receivedUpdate = true; + Queue.offerUnsafe(queue, state); + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + + const initialState = yield* Effect.tryPromise(() => bridge.getUpdateState()).pipe( + Effect.retry({ times: 2 }), + Effect.orElseSucceed(() => null), + ); + if (!receivedUpdate && initialState !== null) { + Queue.offerUnsafe(queue, initialState); + } + + return yield* Effect.never; + }), + ); + + return Atom.make(updates, { initialValue: null }).pipe( + Atom.keepAlive, + Atom.withLabel("desktop:update-state"), + ); +} + +const desktopUpdateStateAtom = createDesktopUpdateStateAtom(getDesktopUpdateBridge); + +export function useDesktopUpdateState(): DesktopUpdateState | null { + return AsyncResult.getOrElse(useAtomValue(desktopUpdateStateAtom), () => null); +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts new file mode 100644 index 00000000000..2d827e36b3a --- /dev/null +++ b/apps/web/src/state/entities.ts @@ -0,0 +1,197 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; +import type { + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThreadActivity, + ScopedProjectRef, + ScopedThreadRef, +} from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentProjects } from "./projects"; +import { environmentThreadDetails, environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); + +const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-project:empty"), +); +const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( + Atom.withLabel("web-project-refs:empty"), +); +const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( + Atom.withLabel("web-thread-refs:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-shell:empty"), +); +const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-detail:empty"), +); +const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( + Atom.withLabel("web-thread-messages:empty"), +); +const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( + Atom.withLabel("web-thread-activities:empty"), +); +const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( + Atom.withLabel("web-thread-proposed-plans:empty"), +); +const EMPTY_SESSION_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-session:empty"), +); + +export const activeEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-active-environment-id"), +); + +export function useActiveEnvironmentId(): EnvironmentId | null { + return useAtomValue(activeEnvironmentIdAtom); +} + +export function readActiveEnvironmentId(): EnvironmentId | null { + return appAtomRegistry.get(activeEnvironmentIdAtom); +} + +export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { + appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); +} + +export function useProjectRefs(): ReadonlyArray { + return useAtomValue(environmentProjects.projectRefsAtom); +} + +export function useThreadRefs(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadRefsAtom); +} + +export function useEnvironmentProjectRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray { + return useAtomValue( + environmentId === null + ? EMPTY_PROJECT_REFS_ATOM + : environmentProjects.environmentProjectRefsAtom(environmentId), + ); +} + +export function useEnvironmentThreadRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray { + return useAtomValue( + environmentId === null + ? EMPTY_THREAD_REFS_ATOM + : environmentThreadShells.environmentThreadRefsAtom(environmentId), + ); +} + +export function useProjects(): ReadonlyArray { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useThreadShells(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useThreadShellsForProjectRefs( + refs: ReadonlyArray, +): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_DETAIL_ATOM : environmentThreadDetails.detailAtom(ref), + ); +} + +/** Detail collections composed with shell-authoritative thread/workspace metadata. */ +export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { + const shell = useThreadShell(ref); + const detail = useThreadDetail(ref); + return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); +} + +export function useThreadMessages( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), + ); +} + +export function useThreadActivities( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), + ); +} + +export function useThreadProposedPlans( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), + ); +} + +export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { + return useAtomValue( + ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), + ); +} + +export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { + return appAtomRegistry.get(environmentProjects.projectAtom(ref)); +} + +export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { + return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); +} + +export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { + return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); +} + +export function readEnvironmentThreadRefs( + environmentId: EnvironmentId, +): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); +} + +export function readThreadRefs(): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); +} + +export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { + return ( + appAtomRegistry + .get(environmentThreadShells.threadRefsAtom) + .find((ref) => ref.threadId === threadId) ?? null + ); +} diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts new file mode 100644 index 00000000000..38d19f90b54 --- /dev/null +++ b/apps/web/src/state/environments.ts @@ -0,0 +1,99 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; +import { useEnvironmentQuery } from "./query"; +import { relayEnvironmentDiscovery } from "./relay"; +import { usePreparedConnection } from "./session"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export const primaryEnvironmentIdAtom = Atom.make((get) => { + for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { + if (entry.target._tag === "PrimaryConnectionTarget") { + return environmentId; + } + } + return null; +}).pipe(Atom.withLabel("web-primary-environment-id")); + +function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function usePrimaryEnvironmentId(): EnvironmentId | null { + return useAtomValue(primaryEnvironmentIdAtom); +} + +export function useEnvironment( + environmentId: EnvironmentId | null, +): EnvironmentPresentation | null { + const { presentation } = useEnvironmentPresentation(environmentId); + return useMemo( + () => + environmentId === null || presentation === null + ? null + : projectEnvironmentPresentation(environmentId, presentation), + [environmentId, presentation], + ); +} + +export function usePrimaryEnvironment(): EnvironmentPresentation | null { + return useEnvironment(usePrimaryEnvironmentId()); +} + +export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): string | null { + const prepared = usePreparedConnection(environmentId); + return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; +} + +export function useRelayEnvironmentDiscovery() { + return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/web/src/state/filesystem.ts b/apps/web/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/web/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/git.ts b/apps/web/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/web/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/orchestration.ts b/apps/web/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/web/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/presentation.ts b/apps/web/src/state/presentation.ts new file mode 100644 index 00000000000..0a4cfd12556 --- /dev/null +++ b/apps/web/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentSession } from "./session"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + configValueAtom: environmentSession.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/web/src/state/preview.ts b/apps/web/src/state/preview.ts new file mode 100644 index 00000000000..af15f38ab77 --- /dev/null +++ b/apps/web/src/state/preview.ts @@ -0,0 +1,5 @@ +import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/web/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts new file mode 100644 index 00000000000..79737e6109e --- /dev/null +++ b/apps/web/src/state/queries.ts @@ -0,0 +1,257 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type CheckpointDiffTarget, + type ComposerPathSearchTarget, +} from "@t3tools/client-runtime/state/threads"; +import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + OrchestrationThread, + ThreadId, + VcsListRefsResult, + VcsRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const COMPOSER_PATH_SEARCH_LIMIT = 80; +const VCS_REF_LIST_LIMIT = 100; +const EMPTY_REFS: ReadonlyArray = []; +const INITIAL_BRANCH_CURSORS = [undefined] as const; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +function useDebouncedValue(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + return useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function usePaginatedBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([target.environmentId, target.cwd, query]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }); + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) { + refs.set(ref.name, ref); + } + } + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + const data: VcsListRefsResult | null = + first === null || last === null + ? null + : { + refs: [...refs.values()], + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + totalCount: Math.max(...values.map((value) => value.totalCount)), + }; + const failed = results.find((result) => result._tag === "Failure"); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + const firstPage = pageAtoms[0]; + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + if (firstPage !== undefined) { + appAtomRegistry.refresh(firstPage); + } + }, [pageAtoms, targetKey]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_REFS, + error, + isPending: results.some((result) => result.waiting), + refresh, + loadNext, + }; +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: target.query?.trim() ?? "", + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff( + target: CheckpointDiffTarget, + options?: { readonly enabled?: boolean }, +) { + const enabled = + options?.enabled !== false && + target.environmentId !== null && + target.threadId !== null && + target.fromTurnCount !== null && + target.toTurnCount !== null; + const fullThreadTarget = + enabled && target.fromTurnCount === 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const turnTarget = + enabled && target.fromTurnCount !== 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + fromTurnCount: target.fromTurnCount!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const fullThread = useEnvironmentQuery( + fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget), + ); + const turn = useEnvironmentQuery( + turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget), + ); + return fullThreadTarget === null ? turn : fullThread; +} diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts new file mode 100644 index 00000000000..2610f1724a0 --- /dev/null +++ b/apps/web/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("web-environment-query:empty"), +); + +export interface EnvironmentQueryView { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery( + atom: Atom.Atom> | null, +): EnvironmentQueryView { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/web/src/state/relay.ts b/apps/web/src/state/relay.ts new file mode 100644 index 00000000000..f078572736b --- /dev/null +++ b/apps/web/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/review.ts b/apps/web/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/web/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts new file mode 100644 index 00000000000..94561f2f207 --- /dev/null +++ b/apps/web/src/state/server.ts @@ -0,0 +1,100 @@ +import { + DEFAULT_SERVER_SETTINGS, + type EditorId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { primaryEnvironmentIdAtom } from "./environments"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { + initialConfigValueAtom: environmentSession.configValueAtom, +}); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + configValueAtom: serverEnvironment.configValueAtom, +}); + +interface PrimaryServerState { + readonly config: ServerConfig | null; + readonly latestEvent: ServerConfigStreamEvent | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { + config: null, + latestEvent: null, + welcome: null, +}; + +export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return EMPTY_PRIMARY_SERVER_STATE; + } + + const target = { environmentId, input: {} }; + const configProjection = Option.getOrNull( + AsyncResult.value(get(serverEnvironment.configProjection(target))), + ); + const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + + return { + config: get(serverEnvironment.configValueAtom(environmentId)), + latestEvent: configProjection?.latestEvent ?? null, + welcome, + }; +}).pipe(Atom.withLabel("web-primary-server-state")); + +export const primaryServerConfigAtom = Atom.make( + (get): ServerConfig | null => get(primaryServerStateAtom).config, +).pipe(Atom.withLabel("web-primary-server-config")); + +export const primaryServerConfigEventAtom = Atom.make( + (get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent, +).pipe(Atom.withLabel("web-primary-server-config-event")); + +export const primaryServerWelcomeAtom = Atom.make( + (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome, +).pipe(Atom.withLabel("web-primary-server-welcome")); + +export const primaryServerSettingsAtom = Atom.make( + (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-primary-server-settings")); + +export const primaryServerProvidersAtom = Atom.make( + (get): ReadonlyArray => + get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, +).pipe(Atom.withLabel("web-primary-server-providers")); + +export const primaryServerKeybindingsAtom = Atom.make( + (get): ServerConfig["keybindings"] => + get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, +).pipe(Atom.withLabel("web-primary-server-keybindings")); + +export const primaryServerAvailableEditorsAtom = Atom.make( + (get): ReadonlyArray => + get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, +).pipe(Atom.withLabel("web-primary-server-available-editors")); + +export const primaryServerKeybindingsConfigPathAtom = Atom.make( + (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, +).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); + +export const primaryServerObservabilityAtom = Atom.make( + (get): ServerConfig["observability"] | null => + get(primaryServerConfigAtom)?.observability ?? null, +).pipe(Atom.withLabel("web-primary-server-observability")); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts new file mode 100644 index 00000000000..37fed3b188f --- /dev/null +++ b/apps/web/src/state/session.ts @@ -0,0 +1,28 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("web-prepared-connection:empty"), +); + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} + +export function readPreparedConnection(environmentId: EnvironmentId) { + return Option.getOrNull( + appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); +} diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/web/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/web/src/state/sourceControl.ts b/apps/web/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/web/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts new file mode 100644 index 00000000000..3f532739f25 --- /dev/null +++ b/apps/web/src/state/sourceControlActions.ts @@ -0,0 +1,356 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + AtomCommandFailure, + AtomCommandResult, + AtomCommandSuccess, +} from "@t3tools/client-runtime/state/runtime"; +import { + VcsActionUnavailableError, + type VcsActionOperation, +} from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + GitActionProgressEvent, + GitResolvePullRequestResult, + GitStackedAction, + SourceControlCloneProtocol, + SourceControlRepositoryVisibility, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { gitEnvironment } from "./git"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; +import { useAtomCommand } from "./use-atom-command"; +import { vcsActionManager, vcsEnvironment } from "./vcs"; + +export type SourceControlActionKind = + | "init" + | "pull" + | "publishRepository" + | "runStackedAction" + | "preparePullRequestThread"; + +export interface SourceControlActionScope { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + +interface SourceControlActionState< + TArgs extends ReadonlyArray, + R extends AtomCommandResult, +> { + readonly isPending: boolean; + readonly error: unknown; + readonly run: ( + ...args: TArgs + ) => Promise< + AtomCommandResult, AtomCommandFailure | VcsActionUnavailableError> + >; + readonly resetError: () => void; +} + +const ACTION_OPERATION = { + init: "init", + pull: "pull", + publishRepository: "publish_repository", + runStackedAction: "run_change_request", + preparePullRequestThread: "prepare_pull_request_thread", +} as const satisfies Record; + +function useAction< + TArgs extends ReadonlyArray, + R extends AtomCommandResult, +>(input: { + readonly kind: SourceControlActionKind; + readonly label: string; + readonly scope: SourceControlActionScope; + readonly action: (...args: TArgs) => Promise; + readonly onSuccess?: () => void; + readonly managedExternally?: boolean; +}): SourceControlActionState { + const operation = ACTION_OPERATION[input.kind]; + const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); + const ownsState = state.operation === operation; + + const resetError = useCallback(() => { + vcsActionManager.resetError(appAtomRegistry, input.scope, operation); + }, [input.scope, operation]); + + const run = useCallback( + async (...args: TArgs) => { + const execute = async (): Promise< + AtomCommandResult, AtomCommandFailure> + > => { + const result = await input.action(...args); + if (AsyncResult.isSuccess(result)) { + input.onSuccess?.(); + } + return result as AtomCommandResult, AtomCommandFailure>; + }; + return input.managedExternally === true + ? execute() + : vcsActionManager.track( + appAtomRegistry, + input.scope, + { + operation, + label: input.label, + }, + execute, + ); + }, + [input.action, input.label, input.managedExternally, input.onSuccess, input.scope, operation], + ); + + return { + error: ownsState ? state.error : null, + isPending: ownsState && state.isRunning, + resetError, + run, + }; +} + +function resolveScope(scope: SourceControlActionScope) { + if (scope.environmentId === null || scope.cwd === null) { + return null; + } + return { + environmentId: scope.environmentId, + cwd: scope.cwd, + }; +} + +function unavailableResult(message: string) { + return AsyncResult.failure( + Cause.fail(new VcsActionUnavailableError({ message })), + ); +} + +export function useSourceControlActionRunning( + scope: SourceControlActionScope, + kinds: ReadonlyArray, +): boolean { + const state = useAtomValue(vcsActionManager.stateAtom(scope)); + return ( + state.isRunning && + state.operation !== null && + kinds.some((kind) => ACTION_OPERATION[kind] === state.operation) + ); +} + +export function useVcsInitAction(scope: SourceControlActionScope) { + const init = useAtomCommand(vcsEnvironment.init, { reportFailure: false }); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return unavailableResult("Git init is unavailable."); + } + return init({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [init, scope]); + return useAction({ kind: "init", label: "Initializing repository", scope, action }); +} + +export function useVcsPullAction(scope: SourceControlActionScope) { + const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback(async () => { + const target = resolveScope(scope); + if (target === null) { + return unavailableResult("Git pull is unavailable."); + } + return pull({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [pull, scope]); + return useAction({ + kind: "pull", + label: "Pulling latest changes", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useGitStackedAction(scope: SourceControlActionScope) { + const runStackedAction = useAtomCommand(vcsActionManager.runStackedAction(scope), { + reportFailure: false, + }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + + const action = useCallback( + async (input: { + actionId: string; + action: GitStackedAction; + commitMessage?: string; + featureBranch?: boolean; + filePaths?: string[]; + onProgress?: (event: GitActionProgressEvent) => void; + }) => { + if (resolveScope(scope) === null) { + return unavailableResult("Git action is unavailable."); + } + return runStackedAction({ + actionId: input.actionId, + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), + ...(input.onProgress ? { onProgress: input.onProgress } : {}), + }); + }, + [runStackedAction, scope], + ); + + return useAction({ + kind: "runStackedAction", + label: "Running source control action", + scope, + action, + onSuccess: status.refresh, + managedExternally: true, + }); +} + +export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback( + async (input: { + provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + repository: string; + visibility: SourceControlRepositoryVisibility; + remoteName: string; + protocol: SourceControlCloneProtocol; + }) => { + const target = resolveScope(scope); + if (target === null) { + return unavailableResult("Repository publishing is unavailable."); + } + return publishRepository({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...input, + }, + }); + }, + [publishRepository, scope], + ); + return useAction({ + kind: "publishRepository", + label: "Publishing repository", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { + const preparePullRequestThread = useAtomCommand(gitEnvironment.preparePullRequestThread, { + reportFailure: false, + }); + const action = useCallback( + async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { + const target = resolveScope(scope); + if (target === null) { + return unavailableResult("Pull request thread preparation is unavailable."); + } + return preparePullRequestThread({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: input.reference, + mode: input.mode, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + }); + }, + [preparePullRequestThread, scope], + ); + return useAction({ + kind: "preparePullRequestThread", + label: "Preparing pull request thread", + scope, + action, + }); +} + +export interface PullRequestResolutionTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly reference: string | null; +} + +export function readCachedPullRequestResolution( + target: PullRequestResolutionTarget, +): GitResolvePullRequestResult | null { + if (target.environmentId === null || target.cwd === null || target.reference === null) { + return null; + } + return Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get( + gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }), + ), + ), + ); +} + +export function usePullRequestResolutionState(target: PullRequestResolutionTarget) { + const query = useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null && target.reference !== null + ? gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: target.reference, + }, + }) + : null, + ); + const cached = readCachedPullRequestResolution(target); + + return { + data: query.data ?? cached, + error: query.error, + isPending: query.isPending && cached === null, + isFetching: query.isPending, + refresh: query.refresh, + }; +} diff --git a/apps/web/src/state/terminal.ts b/apps/web/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/web/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts new file mode 100644 index 00000000000..9e480df08b2 --- /dev/null +++ b/apps/web/src/state/terminalSessions.ts @@ -0,0 +1,90 @@ +import { + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, + selectRunningSubprocessTerminalIds, + type KnownTerminalSession, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { terminalEnvironment } from "./terminal"; + +export function useAttachedTerminalSession(input: { + readonly environmentId: EnvironmentId | null; + readonly terminal: TerminalAttachInput | null; +}): TerminalSessionState { + const attach = useEnvironmentQuery( + input.environmentId !== null && input.terminal !== null + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : null, + ); + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + + return useMemo(() => { + if (input.environmentId === null || input.terminal === null) { + return EMPTY_TERMINAL_SESSION_STATE; + } + const summary = + metadata.data?.find( + (terminal) => + terminal.threadId === input.terminal?.threadId && + terminal.terminalId === input.terminal?.terminalId, + ) ?? null; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); +} + +export function useKnownTerminalSessions(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + return useMemo(() => { + if (input.environmentId === null) { + return []; + } + return (metadata.data ?? []) + .filter((summary) => input.threadId === null || summary.threadId === input.threadId) + .map((summary) => ({ + target: { + environmentId: input.environmentId!, + threadId: ThreadId.make(summary.threadId), + terminalId: summary.terminalId, + }, + state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), + })) + .sort((left, right) => + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { + numeric: true, + }), + ); + }, [input.environmentId, input.threadId, metadata.data]); +} + +export function useThreadRunningTerminalIds(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input)); +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts new file mode 100644 index 00000000000..fd936f99ff2 --- /dev/null +++ b/apps/web/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("web-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/web/src/state/use-atom-command.ts b/apps/web/src/state/use-atom-command.ts new file mode 100644 index 00000000000..37ce280e9f4 --- /dev/null +++ b/apps/web/src/state/use-atom-command.ts @@ -0,0 +1,23 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + type AtomCommand, + type AtomCommandOptions, + type AtomCommandResult, + runAtomCommand, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useContext } from "react"; + +export function useAtomCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const registry = useContext(RegistryContext); + const label = typeof options === "string" ? options : (options?.label ?? command.label); + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (value: W) => runAtomCommand(registry, command, value, { label, reportFailure, reportDefect }), + [command, label, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/web/src/state/use-atom-query-runner.ts b/apps/web/src/state/use-atom-query-runner.ts new file mode 100644 index 00000000000..22f971e09a5 --- /dev/null +++ b/apps/web/src/state/use-atom-query-runner.ts @@ -0,0 +1,30 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + executeAtomQuery, + type AtomCommandOptions, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext } from "react"; + +export function useAtomQueryRunner( + family: (target: T) => Atom.Atom>, + options?: string | AtomCommandOptions, +): (target: T) => Promise> { + const registry = useContext(RegistryContext); + const explicitLabel = typeof options === "string" ? options : options?.label; + const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); + const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + + return useCallback( + (target: T) => { + const atom = family(target); + return executeAtomQuery(registry, atom, { + label: explicitLabel ?? atom.label?.[0] ?? "atom query", + reportFailure, + reportDefect, + }); + }, + [explicitLabel, family, registry, reportDefect, reportFailure], + ); +} diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts new file mode 100644 index 00000000000..dc8c251149f --- /dev/null +++ b/apps/web/src/state/vcs.ts @@ -0,0 +1,9 @@ +import { + createVcsActionManager, + createVcsEnvironmentAtoms, +} from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 2fe06d518d2..00000000000 --- a/apps/web/src/store.test.ts +++ /dev/null @@ -1,1083 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - CheckpointRef, - DEFAULT_MODEL, - EnvironmentId, - EventId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationEvent, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - applyOrchestrationEvent, - applyOrchestrationEvents, - removeEnvironmentState, - selectEnvironmentState, - selectProjectsAcrossEnvironments, - selectThreadByRef, - selectThreadExistsByRef, - setThreadBranch, - selectThreadsAcrossEnvironments, - type AppState, - type EnvironmentState, -} from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; - -const localEnvironmentId = EnvironmentId.make("environment-local"); -const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - -function withActiveEnvironmentState( - environmentState: EnvironmentState, - overrides: Partial = {}, -): AppState { - const { - activeEnvironmentId: overrideActiveEnvironmentId, - environmentStateById: overrideEnvironmentStateById, - ...environmentOverrides - } = overrides; - const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; - const mergedEnvironmentState = { - ...environmentState, - ...environmentOverrides, - }; - const environmentStateById = - overrideEnvironmentStateById ?? - (activeEnvironmentId - ? { - [activeEnvironmentId]: mergedEnvironmentState, - } - : {}); - - return { - activeEnvironmentId, - environmentStateById, - }; -} - -function makeThread(overrides: Partial = {}): Thread { - return { - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - messages: [], - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - error: null, - createdAt: "2026-02-13T00:00:00.000Z", - archivedAt: null, - latestTurn: null, - branch: null, - worktreePath: null, - ...overrides, - }; -} - -function makeState(thread: Thread): AppState { - const projectId = ProjectId.make("project-1"); - const project = { - id: projectId, - environmentId: thread.environmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-13T00:00:00.000Z", - updatedAt: "2026-02-13T00:00:00.000Z", - scripts: [], - }; - const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { - [thread.projectId]: [thread.id], - }; - const environmentState = { - projectIds: [projectId], - projectById: { - [projectId]: project, - }, - threadIds: [thread.id], - threadIdsByProjectId, - threadShellById: { - [thread.id]: { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }, - threadSessionById: { - [thread.id]: thread.session, - }, - threadTurnStateById: { - [thread.id]: { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }, - }, - messageIdsByThreadId: { - [thread.id]: thread.messages.map((message) => message.id), - }, - messageByThreadId: { - [thread.id]: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as EnvironmentState["messageByThreadId"][ThreadId], - }, - activityIdsByThreadId: { - [thread.id]: thread.activities.map((activity) => activity.id), - }, - activityByThreadId: { - [thread.id]: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as EnvironmentState["activityByThreadId"][ThreadId], - }, - proposedPlanIdsByThreadId: { - [thread.id]: thread.proposedPlans.map((plan) => plan.id), - }, - proposedPlanByThreadId: { - [thread.id]: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], - }, - turnDiffIdsByThreadId: { - [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), - }, - turnDiffSummaryByThreadId: { - [thread.id]: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], - }, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, { - activeEnvironmentId: thread.environmentId, - }); -} - -function makeEmptyState(overrides: Partial = {}): AppState { - const environmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, overrides); -} - -function localEnvironmentStateOf(state: AppState): EnvironmentState { - return selectEnvironmentState(state, localEnvironmentId); -} - -function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState { - return selectEnvironmentState(state, environmentId); -} - -function projectsOf(state: AppState) { - return selectProjectsAcrossEnvironments(state); -} - -function threadsOf(state: AppState) { - return selectThreadsAcrossEnvironments(state); -} - -function makeEvent( - type: T, - payload: Extract["payload"], - overrides: Partial> = {}, -): Extract { - const sequence = overrides.sequence ?? 1; - return { - sequence, - eventId: EventId.make(`event-${sequence}`), - aggregateKind: "thread", - aggregateId: - "threadId" in payload - ? payload.threadId - : "projectId" in payload - ? payload.projectId - : ProjectId.make("project-1"), - occurredAt: "2026-02-27T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type, - payload, - ...overrides, - } as Extract; -} - -describe("environment state removal", () => { - it("drops local state for removed environments", () => { - const removedThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-removed"), - }); - const keptThread = makeThread({ id: ThreadId.make("thread-kept") }); - const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!; - const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!; - const state: AppState = { - activeEnvironmentId: remoteEnvironmentId, - environmentStateById: { - [remoteEnvironmentId]: removedState, - [localEnvironmentId]: keptState, - }, - }; - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next.activeEnvironmentId).toBeNull(); - expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined(); - expect(next.environmentStateById[localEnvironmentId]).toBe(keptState); - }); - - it("preserves active environment when removing a different environment", () => { - const state = makeState(makeThread()); - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next).toBe(state); - }); -}); - -describe("thread selection memoization", () => { - it("returns stable thread references for repeated reads of the same state", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "user", - text: "hello", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "working", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-13T00:01:30.000Z", - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: null, - planMarkdown: "plan", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-13T00:02:00.000Z", - updatedAt: "2026-02-13T00:02:00.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-13T00:03:00.000Z", - files: [], - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(state, ref); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(second?.messages).toBe(first?.messages); - expect(second?.activities).toBe(first?.activities); - expect(second?.proposedPlans).toBe(first?.proposedPlans); - expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); - }); - - it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "done", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const wrappedState: AppState = { - ...state, - environmentStateById: { ...state.environmentStateById }, - }; - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(wrappedState, ref); - - expect(second).toBe(first); - }); - - it("updates the derived thread when the underlying thread data changes", () => { - const thread = makeThread(); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const firstState = makeState(thread); - const secondState = makeState({ - ...thread, - messages: [ - { - id: MessageId.make("message-2"), - role: "user", - text: "new", - createdAt: "2026-02-13T00:04:00.000Z", - streaming: false, - }, - ], - }); - - const first = selectThreadByRef(firstState, ref); - const second = selectThreadByRef(secondState, ref); - - expect(second).not.toBe(first); - expect(second?.messages).toHaveLength(1); - expect(second?.messages[0]?.text).toBe("new"); - }); - - it("checks thread existence without materializing the full thread", () => { - const thread = makeThread(); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - expect(selectThreadExistsByRef(state, ref)).toBe(true); - expect( - selectThreadExistsByRef( - state, - scopeThreadRef(thread.environmentId, ThreadId.make("missing")), - ), - ).toBe(false); - expect(selectThreadExistsByRef(state, null)).toBe(false); - }); -}); - -describe("setThreadBranch", () => { - it("updates only the scoped thread environment", () => { - const sharedThreadId = ThreadId.make("thread-shared"); - const localThread = makeThread({ - id: sharedThreadId, - environmentId: localEnvironmentId, - branch: "local-branch", - }); - const remoteThread = makeThread({ - id: sharedThreadId, - environmentId: remoteEnvironmentId, - branch: "remote-branch", - }); - const state: AppState = { - activeEnvironmentId: localEnvironmentId, - environmentStateById: { - [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), - [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), - }, - }; - - const next = setThreadBranch( - state, - scopeThreadRef(remoteEnvironmentId, sharedThreadId), - "remote-next", - "/tmp/remote-worktree", - ); - - expect( - environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("local-branch"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("remote-next"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, - ).toBe("/tmp/remote-worktree"); - }); -}); - -describe("incremental orchestration updates", () => { - it("does not mark bootstrap complete for incremental events", () => { - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { - bootstrapComplete: false, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.meta-updated", { - threadId: ThreadId.make("thread-1"), - title: "Updated title", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); - }); - - it("preserves state identity for no-op project and thread deletes", () => { - const thread = makeThread(); - const state = makeState(thread); - - const nextAfterProjectDelete = applyOrchestrationEvent( - state, - makeEvent("project.deleted", { - projectId: ProjectId.make("project-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - const nextAfterThreadDelete = applyOrchestrationEvent( - state, - makeEvent("thread.deleted", { - threadId: ThreadId.make("thread-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(nextAfterProjectDelete).toBe(state); - expect(nextAfterThreadDelete).toBe(state); - }); - - it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const state: AppState = makeEmptyState({ - projectIds: [originalProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("project.created", { - projectId: recreatedProjectId, - title: "Project Recreated", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - scripts: [], - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(projectsOf(next)).toHaveLength(1); - expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); - expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); - expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); - expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); - expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( - recreatedProjectId, - ); - }); - - it("removes stale project index entries when thread.created recreates a thread under a new project", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const threadId = ThreadId.make("thread-1"); - const thread = makeThread({ - id: threadId, - projectId: originalProjectId, - }); - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { - projectIds: [originalProjectId, recreatedProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project 1", - cwd: "/tmp/project-1", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - [recreatedProjectId]: { - id: recreatedProjectId, - environmentId: localEnvironmentId, - name: "Project 2", - cwd: "/tmp/project-2", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.created", { - threadId, - projectId: recreatedProjectId, - title: "Recovered thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)).toHaveLength(1); - expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ - threadId, - ]); - }); - - it("updates only the affected thread for message events", () => { - const thread1 = makeThread({ - id: ThreadId.make("thread-1"), - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - ], - }); - const thread2 = makeThread({ id: ThreadId.make("thread-2") }); - const baseState = makeState(thread1); - const baseEnvironmentState = localEnvironmentStateOf(baseState); - const state = withActiveEnvironmentState(baseEnvironmentState, { - threadIds: [thread1.id, thread2.id], - threadShellById: { - ...baseEnvironmentState.threadShellById, - [thread2.id]: { - id: thread2.id, - environmentId: thread2.environmentId, - codexThreadId: thread2.codexThreadId, - projectId: thread2.projectId, - title: thread2.title, - modelSelection: thread2.modelSelection, - runtimeMode: thread2.runtimeMode, - interactionMode: thread2.interactionMode, - error: thread2.error, - createdAt: thread2.createdAt, - archivedAt: thread2.archivedAt, - updatedAt: thread2.updatedAt, - branch: thread2.branch, - worktreePath: thread2.worktreePath, - }, - }, - threadSessionById: { - ...baseEnvironmentState.threadSessionById, - [thread2.id]: thread2.session, - }, - threadTurnStateById: { - ...baseEnvironmentState.threadTurnStateById, - [thread2.id]: { - latestTurn: thread2.latestTurn, - }, - }, - messageIdsByThreadId: { - ...baseEnvironmentState.messageIdsByThreadId, - [thread2.id]: [], - }, - messageByThreadId: { - ...baseEnvironmentState.messageByThreadId, - [thread2.id]: {}, - }, - activityIdsByThreadId: { - ...baseEnvironmentState.activityIdsByThreadId, - [thread2.id]: [], - }, - activityByThreadId: { - ...baseEnvironmentState.activityByThreadId, - [thread2.id]: {}, - }, - proposedPlanIdsByThreadId: { - ...baseEnvironmentState.proposedPlanIdsByThreadId, - [thread2.id]: [], - }, - proposedPlanByThreadId: { - ...baseEnvironmentState.proposedPlanByThreadId, - [thread2.id]: {}, - }, - turnDiffIdsByThreadId: { - ...baseEnvironmentState.turnDiffIdsByThreadId, - [thread2.id]: [], - }, - turnDiffSummaryByThreadId: { - ...baseEnvironmentState.turnDiffSummaryByThreadId, - [thread2.id]: {}, - }, - sidebarThreadSummaryById: { - ...baseEnvironmentState.sidebarThreadSummaryById, - }, - threadIdsByProjectId: { - [thread1.projectId]: [thread1.id, thread2.id], - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: thread1.id, - messageId: MessageId.make("message-1"), - role: "assistant", - text: " world", - turnId: TurnId.make("turn-1"), - streaming: true, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; - const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; - expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( - previousEnvironmentState?.threadShellById[thread2.id], - ); - expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( - previousEnvironmentState?.threadSessionById[thread2.id], - ); - expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageIdsByThreadId[thread2.id], - ); - expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageByThreadId[thread2.id], - ); - }); - - it("applies replay batches in sequence and updates session state", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "running", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }, - }); - const state = makeState(thread); - - const next = applyOrchestrationEvents( - state, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", - }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.make("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ], - localEnvironmentId, - ); - - // A completed assistant message must not settle the turn while the - // session is still running it — providers emit interim assistant - // messages between tool calls. - expect(threadsOf(next)[0]?.session?.status).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.completedAt).toBeNull(); - expect(threadsOf(next)[0]?.messages).toHaveLength(1); - - const settled = applyOrchestrationEvents( - next, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }, - { sequence: 4 }, - ), - ], - localEnvironmentId, - ); - - // Leaving the running session status is the turn-end signal. - expect(threadsOf(settled)[0]?.latestTurn?.state).toBe("completed"); - expect(threadsOf(settled)[0]?.latestTurn?.completedAt).toBe("2026-02-27T00:00:04.000Z"); - }); - - it("does not regress latestTurn when an older turn diff completes late", () => { - const state = makeState( - makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "running", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:03.000Z", - completedAt: null, - assistantMessageId: null, - }, - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.turn-diff-completed", { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("assistant-1"), - completedAt: "2026-02-27T00:00:04.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); - expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); - }); - - it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { - const turnId = TurnId.make("turn-1"); - const state = makeState( - makeThread({ - latestTurn: { - turnId, - state: "completed", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - assistantMessageId: MessageId.make("assistant:turn-1"), - }, - turnDiffSummaries: [ - { - turnId, - completedAt: "2026-02-27T00:00:02.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - assistantMessageId: MessageId.make("assistant:turn-1"), - files: [{ path: "src/app.ts", additions: 1, deletions: 0 }], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-real"), - role: "assistant", - text: "final answer", - turnId, - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - }); - - it("reverts messages, plans, activities, and checkpoints by retained turns", () => { - const state = makeState( - makeThread({ - messages: [ - { - id: MessageId.make("user-1"), - role: "user", - text: "first", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - { - id: MessageId.make("assistant-1"), - role: "assistant", - text: "first reply", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:01.000Z", - completedAt: "2026-02-27T00:00:01.000Z", - streaming: false, - }, - { - id: MessageId.make("user-2"), - role: "user", - text: "second", - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - streaming: false, - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-1"), - planMarkdown: "plan 1", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-2"), - planMarkdown: "plan 2", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:02.000Z", - updatedAt: "2026-02-27T00:00:02.000Z", - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "one", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - }, - { - id: EventId.make("activity-2"), - tone: "info", - kind: "step", - summary: "two", - payload: {}, - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.reverted", { - threadId: ThreadId.make("thread-1"), - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant-1", - ]); - expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); - expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ - EventId.make("activity-1"), - ]); - expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - TurnId.make("turn-1"), - ]); - }); - - it("clears pending source proposed plans after revert before a new session-set event", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "completed", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:03.000Z", - assistantMessageId: MessageId.make("assistant-2"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - }, - pendingSourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }); - const reverted = applyOrchestrationEvent( - makeState(thread), - makeEvent("thread.reverted", { - threadId: thread.id, - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); - - const next = applyOrchestrationEvent( - reverted, - makeEvent("thread.session-set", { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-3"), - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ - turnId: TurnId.make("turn-3"), - state: "running", - }); - expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts deleted file mode 100644 index 9a9b05f92f2..00000000000 --- a/apps/web/src/store.ts +++ /dev/null @@ -1,2050 +0,0 @@ -import type { - EnvironmentId, - MessageId, - OrchestrationCheckpointSummary, - OrchestrationEvent, - OrchestrationLatestTurn, - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationReadModel, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - OrchestrationSession, - OrchestrationSessionStatus, - OrchestrationThread, - OrchestrationThreadShell, - OrchestrationThreadActivity, - ProjectId, - ScopedProjectRef, - ScopedThreadRef, -} from "@t3tools/contracts"; -import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; -import type { ThreadId, TurnId } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { resolveModelSlugForProvider } from "@t3tools/shared/model"; -import { create } from "zustand"; -import { - type ChatMessage, - type Project, - type ProposedPlan, - type SidebarThreadSummary, - type Thread, - type ThreadSession, - type ThreadShell, - type ThreadTurnState, - type TurnDiffSummary, -} from "./types"; -import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; -const isProviderDriverKindValue = Schema.is(ProviderDriverKind); - -export interface EnvironmentState { - projectIds: ProjectId[]; - projectById: Record; - - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Web still stores shell snapshots and thread details in this denormalized - // Zustand shape. Mobile uses createShellSnapshotManager and - // createThreadDetailManager from @t3tools/client-runtime. New shared behavior - // belongs in those managers/reducers, with a web adapter layered on top. - // - // --------------------------------------------------------------------------- - // Thread bookkeeping — written by BOTH shell stream and detail stream. - // Both streams ensure the thread is registered here; the bookkeeping is - // additive (append-only IDs) so concurrent writes are safe. - // --------------------------------------------------------------------------- - threadIds: ThreadId[]; - threadIdsByProjectId: Record; - - // --------------------------------------------------------------------------- - // Thread shell / session / turn — written by BOTH shell stream and detail - // stream. The shell stream is the *authoritative* source (server pre- - // computes these from the projection pipeline), but the detail stream also - // writes them so the active thread has up-to-date state even if the shell - // event hasn't arrived yet. Structural equality checks in both write - // functions prevent unnecessary React re-renders when both streams deliver - // equivalent data. - // --------------------------------------------------------------------------- - threadShellById: Record; - threadSessionById: Record; - threadTurnStateById: Record; - - // --------------------------------------------------------------------------- - // Thread detail content — written ONLY by the detail stream - // (writeThreadState / syncServerThreadDetail). The shell stream never - // touches these. - // --------------------------------------------------------------------------- - messageIdsByThreadId: Record; - messageByThreadId: Record>; - activityIdsByThreadId: Record; - activityByThreadId: Record>; - proposedPlanIdsByThreadId: Record; - proposedPlanByThreadId: Record>; - turnDiffIdsByThreadId: Record; - turnDiffSummaryByThreadId: Record>; - - // --------------------------------------------------------------------------- - // Sidebar summary — written ONLY by the shell stream - // (writeThreadShellState / mapThreadShell). Pre-computed server-side with - // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail - // stream must NOT write here; the shell stream is the single source of - // truth for sidebar data. - // --------------------------------------------------------------------------- - sidebarThreadSummaryById: Record; - - bootstrapComplete: boolean; -} - -export interface AppState { - activeEnvironmentId: EnvironmentId | null; - environmentStateById: Record; -} - -const initialEnvironmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, -}; - -const initialState: AppState = { - activeEnvironmentId: null, - environmentStateById: {}, -}; - -const MAX_THREAD_MESSAGES = 2_000; -const MAX_THREAD_CHECKPOINTS = 500; -const MAX_THREAD_PROPOSED_PLANS = 200; -const MAX_THREAD_ACTIVITIES = 500; -const EMPTY_THREAD_IDS: ThreadId[] = []; - -function arraysEqual(left: readonly T[], right: readonly T[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -// Accepts the open `instanceId` string carried on `ModelSelection`; malformed -// values pass through unchanged, while valid slugs use any registered alias -// table for model normalization. -function normalizeModelSelection(selection: T): T { - if (!isProviderDriverKind(selection.instanceId)) { - return selection; - } - return { - ...selection, - model: resolveModelSlugForProvider(selection.instanceId, selection.model), - }; -} - -function mapProjectScripts(scripts: ReadonlyArray): Project["scripts"] { - return scripts.map((script) => ({ ...script })); -} - -function mapSession(session: OrchestrationSession): ThreadSession { - return { - provider: toLegacyProvider(session.providerName), - providerInstanceId: session.providerInstanceId ?? undefined, - status: toLegacySessionStatus(session.status), - orchestrationStatus: session.status, - activeTurnId: session.activeTurnId ?? undefined, - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - ...(session.lastError ? { lastError: session.lastError } : {}), - }; -} - -function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })); - - return { - id: message.id, - role: message.role, - text: message.text, - turnId: message.turnId, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; -} - -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { - return { - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - implementedAt: proposedPlan.implementedAt, - implementationThreadId: proposedPlan.implementationThreadId, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - }; -} - -function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { - return { - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - }; -} - -function mapProject( - project: - | OrchestrationReadModel["projects"][number] - | OrchestrationShellSnapshot["projects"][number], - environmentId: EnvironmentId, -): Project { - return { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), - }; -} - -function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { - return { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - session: thread.session ? mapSession(thread.session) : null, - messages: thread.messages.map((message) => mapMessage(environmentId, message)), - proposedPlans: thread.proposedPlans.map(mapProposedPlan), - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), - activities: thread.activities.map((activity) => ({ ...activity })), - }; -} - -function mapThreadShell( - thread: OrchestrationThreadShell, - environmentId: EnvironmentId, -): { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; -} { - const shell: ThreadShell = { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }; - const session = thread.session ? mapSession(thread.session) : null; - const turnState: ThreadTurnState = { - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - }; - const summary: SidebarThreadSummary = { - id: thread.id, - environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: thread.latestUserMessageAt, - hasPendingApprovals: thread.hasPendingApprovals, - hasPendingUserInput: thread.hasPendingUserInput, - hasActionableProposedPlan: thread.hasActionableProposedPlan, - }; - return { - shell, - session, - turnState, - summary, - }; -} - -function toThreadShell(thread: Thread): ThreadShell { - return { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }; -} - -function toThreadTurnState(thread: Thread): ThreadTurnState { - return { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }; -} - -function sourceProposedPlansEqual( - left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, - right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, -): boolean { - if (left === right) return true; - if (left === undefined || right === undefined) return false; - return left.threadId === right.threadId && left.planId === right.planId; -} - -function latestTurnsEqual( - left: OrchestrationLatestTurn | null | undefined, - right: OrchestrationLatestTurn | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.turnId === right.turnId && - left.state === right.state && - left.requestedAt === right.requestedAt && - left.startedAt === right.startedAt && - left.completedAt === right.completedAt && - left.assistantMessageId === right.assistantMessageId && - sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) - ); -} - -function threadSessionsEqual( - left: ThreadSession | null | undefined, - right: ThreadSession | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.provider === right.provider && - left.status === right.status && - left.orchestrationStatus === right.orchestrationStatus && - left.activeTurnId === right.activeTurnId && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.lastError === right.lastError - ); -} - -function sidebarThreadSummariesEqual( - left: SidebarThreadSummary | undefined, - right: SidebarThreadSummary, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.projectId === right.projectId && - left.title === right.title && - left.interactionMode === right.interactionMode && - threadSessionsEqual(left.session, right.session) && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - left.branch === right.branch && - left.worktreePath === right.worktreePath && - left.latestUserMessageAt === right.latestUserMessageAt && - left.hasPendingApprovals === right.hasPendingApprovals && - left.hasPendingUserInput === right.hasPendingUserInput && - left.hasActionableProposedPlan === right.hasActionableProposedPlan - ); -} - -function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.codexThreadId === right.codexThreadId && - left.projectId === right.projectId && - left.title === right.title && - left.modelSelection === right.modelSelection && - left.runtimeMode === right.runtimeMode && - left.interactionMode === right.interactionMode && - left.error === right.error && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - left.branch === right.branch && - left.worktreePath === right.worktreePath - ); -} - -function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { - return ( - left !== undefined && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) - ); -} - -function appendId(ids: readonly T[], id: T): T[] { - return ids.includes(id) ? [...ids] : [...ids, id]; -} - -function removeId(ids: readonly T[], id: T): T[] { - return ids.filter((value) => value !== id); -} - -function buildMessageSlice(thread: Thread): { - ids: MessageId[]; - byId: Record; -} { - return { - ids: thread.messages.map((message) => message.id), - byId: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as Record, - }; -} - -function buildActivitySlice(thread: Thread): { - ids: string[]; - byId: Record; -} { - return { - ids: thread.activities.map((activity) => activity.id), - byId: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as Record, - }; -} - -function buildProposedPlanSlice(thread: Thread): { - ids: string[]; - byId: Record; -} { - return { - ids: thread.proposedPlans.map((plan) => plan.id), - byId: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as Record, - }; -} - -function buildTurnDiffSlice(thread: Thread): { - ids: TurnId[]; - byId: Record; -} { - return { - ids: thread.turnDiffSummaries.map((summary) => summary.turnId), - byId: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as Record, - }; -} - -function getProjects(state: EnvironmentState): Project[] { - return state.projectIds.flatMap((projectId) => { - const project = state.projectById[projectId]; - return project ? [project] : []; - }); -} - -function getThreads(state: EnvironmentState): Thread[] { - return state.threadIds.flatMap((threadId) => { - const thread = getThreadFromEnvironmentState(state, threadId); - return thread ? [thread] : []; - }); -} - -/** - * Ensure a thread is registered in the bookkeeping indices (threadIds, - * threadIdsByProjectId). Shared by both the shell stream and detail stream - * write paths — the bookkeeping is additive (append-only IDs) so concurrent - * writes from both streams are safe. - */ -function ensureThreadRegistered( - state: EnvironmentState, - threadId: ThreadId, - nextProjectId: ProjectId, - previousProjectId: ProjectId | undefined, -): EnvironmentState { - let nextState = state; - - if (!state.threadIds.includes(threadId)) { - nextState = { - ...nextState, - threadIds: [...nextState.threadIds, threadId], - }; - } - - if (previousProjectId !== nextProjectId) { - let threadIdsByProjectId = nextState.threadIdsByProjectId; - if (previousProjectId) { - const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, threadId); - if (nextIds.length === 0) { - const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; - threadIdsByProjectId = rest as Record; - } else if (!arraysEqual(previousIds, nextIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [previousProjectId]: nextIds, - }; - } - } - const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, threadId); - if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [nextProjectId]: nextProjectThreadIds, - }; - } - if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { - nextState = { - ...nextState, - threadIdsByProjectId, - }; - } - } - - return nextState; -} - -/** - * Write thread state from the **detail stream** (per-thread subscription). - * - * Owns: messages, activities, proposed plans, turn diff summaries. - * Also writes threadShellById / threadSessionById / threadTurnStateById so - * the active thread has up-to-date state even if the shell stream event - * hasn't arrived yet (both streams use structural equality checks to avoid - * unnecessary re-renders when delivering equivalent data). - * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. - */ -function writeThreadState( - state: EnvironmentState, - nextThread: Thread, - previousThread?: Thread, -): EnvironmentState { - const nextShell = toThreadShell(nextThread); - const nextTurnState = toThreadTurnState(nextThread); - const previousShell = state.threadShellById[nextThread.id]; - const previousTurnState = state.threadTurnStateById[nextThread.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.id, - nextThread.projectId, - previousThread?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextShell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.id]: nextShell, - }, - }; - } - - if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.id]: nextThread.session, - }, - }; - } - - if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.id]: nextTurnState, - }, - }; - } - - if (previousThread?.messages !== nextThread.messages) { - const nextMessageSlice = buildMessageSlice(nextThread); - nextState = { - ...nextState, - messageIdsByThreadId: { - ...nextState.messageIdsByThreadId, - [nextThread.id]: nextMessageSlice.ids, - }, - messageByThreadId: { - ...nextState.messageByThreadId, - [nextThread.id]: nextMessageSlice.byId, - }, - }; - } - - if (previousThread?.activities !== nextThread.activities) { - const nextActivitySlice = buildActivitySlice(nextThread); - nextState = { - ...nextState, - activityIdsByThreadId: { - ...nextState.activityIdsByThreadId, - [nextThread.id]: nextActivitySlice.ids, - }, - activityByThreadId: { - ...nextState.activityByThreadId, - [nextThread.id]: nextActivitySlice.byId, - }, - }; - } - - if (previousThread?.proposedPlans !== nextThread.proposedPlans) { - const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); - nextState = { - ...nextState, - proposedPlanIdsByThreadId: { - ...nextState.proposedPlanIdsByThreadId, - [nextThread.id]: nextProposedPlanSlice.ids, - }, - proposedPlanByThreadId: { - ...nextState.proposedPlanByThreadId, - [nextThread.id]: nextProposedPlanSlice.byId, - }, - }; - } - - if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { - const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); - nextState = { - ...nextState, - turnDiffIdsByThreadId: { - ...nextState.turnDiffIdsByThreadId, - [nextThread.id]: nextTurnDiffSlice.ids, - }, - turnDiffSummaryByThreadId: { - ...nextState.turnDiffSummaryByThreadId, - [nextThread.id]: nextTurnDiffSlice.byId, - }, - }; - } - - return nextState; -} - -/** - * Write thread state from the **shell stream** (all-threads subscription). - * - * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). - * Also writes threadShellById / threadSessionById / threadTurnStateById as - * the authoritative source for these fields. The detail stream may also - * write them for the focused thread (see writeThreadState); structural - * equality checks prevent unnecessary re-renders. - * Does NOT write message/activity/proposedPlan/turnDiff content — that is - * detail-stream-only. - */ -function writeThreadShellState( - state: EnvironmentState, - nextThread: { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; - }, -): EnvironmentState { - const previousShell = state.threadShellById[nextThread.shell.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.shell.id, - nextThread.shell.projectId, - previousShell?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextThread.shell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.shell.id]: nextThread.shell, - }, - }; - } - - if ( - !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) - ) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.shell.id]: nextThread.session, - }, - }; - } - - if ( - !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) - ) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.shell.id]: nextThread.turnState, - }, - }; - } - - if ( - !sidebarThreadSummariesEqual( - state.sidebarThreadSummaryById[nextThread.shell.id], - nextThread.summary, - ) - ) { - nextState = { - ...nextState, - sidebarThreadSummaryById: { - ...nextState.sidebarThreadSummaryById, - [nextThread.shell.id]: nextThread.summary, - }, - }; - } - - return nextState; -} - -function retainThreadScopedRecord( - record: Record, - nextThreadIds: ReadonlySet, -): Record { - return Object.fromEntries( - Object.entries(record).flatMap(([threadId, value]) => - nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], - ), - ) as Record; -} - -function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { - const shell = state.threadShellById[threadId]; - if (!shell) { - return state; - } - - const nextThreadIds = removeId(state.threadIds, threadId); - const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); - const nextThreadIdsByProjectId = - nextProjectThreadIds.length === 0 - ? (() => { - const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; - return rest as Record; - })() - : { - ...state.threadIdsByProjectId, - [shell.projectId]: nextProjectThreadIds, - }; - - const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; - const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; - const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; - const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; - const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; - const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; - const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; - const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = - state.proposedPlanIdsByThreadId; - const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; - const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; - const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = - state.turnDiffSummaryByThreadId; - const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = - state.sidebarThreadSummaryById; - - return { - ...state, - threadIds: nextThreadIds, - threadIdsByProjectId: nextThreadIdsByProjectId, - threadShellById, - threadSessionById, - threadTurnStateById, - messageIdsByThreadId, - messageByThreadId, - activityIdsByThreadId, - activityByThreadId, - proposedPlanIdsByThreadId, - proposedPlanByThreadId, - turnDiffIdsByThreadId, - turnDiffSummaryByThreadId, - sidebarThreadSummaryById, - }; -} - -function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { - if (status === "error") { - return "error" as const; - } - if (status === "missing") { - return "interrupted" as const; - } - return "completed" as const; -} - -function compareActivities( - left: Thread["activities"][number], - right: Thread["activities"][number], -): number { - if (left.sequence !== undefined && right.sequence !== undefined) { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - } else if (left.sequence !== undefined) { - return 1; - } else if (right.sequence !== undefined) { - return -1; - } - - return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); -} - -function buildLatestTurn(params: { - previous: Thread["latestTurn"]; - turnId: NonNullable["turnId"]; - state: NonNullable["state"]; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - assistantMessageId: NonNullable["assistantMessageId"]; - sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; -}): NonNullable { - const resolvedPlan = - params.previous?.turnId === params.turnId - ? params.previous.sourceProposedPlan - : params.sourceProposedPlan; - return { - turnId: params.turnId, - state: params.state, - requestedAt: params.requestedAt, - startedAt: params.startedAt, - completedAt: params.completedAt, - assistantMessageId: params.assistantMessageId, - ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), - }; -} - -/** - * Turn state to settle a still-running latest turn with when its session - * leaves the "running" status, or null while the session is (re)starting or - * running and the turn must stay unsettled. - */ -function settledTurnStateForSessionStatus( - status: OrchestrationSessionStatus, -): "completed" | "interrupted" | "error" | null { - switch (status) { - case "idle": - case "ready": - return "completed"; - case "error": - return "error"; - case "interrupted": - case "stopped": - return "interrupted"; - case "starting": - case "running": - return null; - } -} - -function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: TurnId, - assistantMessageId: NonNullable["assistantMessageId"], -): TurnDiffSummary[] { - let changed = false; - const nextSummaries = turnDiffSummaries.map((summary) => { - if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { - return summary; - } - changed = true; - return { - ...summary, - assistantMessageId: assistantMessageId ?? undefined, - }; - }); - return changed ? nextSummaries : [...turnDiffSummaries]; -} - -function retainThreadMessagesAfterRevert( - messages: ReadonlyArray, - retainedTurnIds: ReadonlySet, - turnCount: number, -): ChatMessage[] { - const retainedMessageIds = new Set(); - for (const message of messages) { - if (message.role === "system") { - retainedMessageIds.add(message.id); - continue; - } - if ( - message.turnId !== undefined && - message.turnId !== null && - retainedTurnIds.has(message.turnId) - ) { - retainedMessageIds.add(message.id); - } - } - - const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), - ).length; - const missingUserCount = Math.max(0, turnCount - retainedUserCount); - if (missingUserCount > 0) { - const fallbackUserMessages = messages - .filter( - (message) => - message.role === "user" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingUserCount); - for (const message of fallbackUserMessages) { - retainedMessageIds.add(message.id); - } - } - - const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), - ).length; - const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); - if (missingAssistantCount > 0) { - const fallbackAssistantMessages = messages - .filter( - (message) => - message.role === "assistant" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingAssistantCount); - for (const message of fallbackAssistantMessages) { - retainedMessageIds.add(message.id); - } - } - - return messages.filter((message) => retainedMessageIds.has(message.id)); -} - -function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): OrchestrationThreadActivity[] { - return activities.filter( - (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), - ); -} - -function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, - retainedTurnIds: ReadonlySet, -): ProposedPlan[] { - return proposedPlans.filter( - (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), - ); -} - -function toLegacySessionStatus( - status: OrchestrationSessionStatus, -): "connecting" | "ready" | "running" | "error" | "closed" { - switch (status) { - case "starting": - return "connecting"; - case "running": - return "running"; - case "error": - return "error"; - case "ready": - case "interrupted": - return "ready"; - case "idle": - case "stopped": - return "closed"; - } -} - -function toLegacyProvider(providerName: string | null): ProviderDriverKind { - if (isProviderDriverKindValue(providerName)) { - return providerName; - } - return ProviderDriverKind.make("codex"); -} - -function updateThreadState( - state: EnvironmentState, - threadId: ThreadId, - updater: (thread: Thread) => Thread, -): EnvironmentState { - const currentThread = getThreadFromEnvironmentState(state, threadId); - if (!currentThread) { - return state; - } - const nextThread = updater(currentThread); - if (nextThread === currentThread) { - return state; - } - return writeThreadState(state, nextThread, currentThread); -} - -function buildProjectState( - projects: ReadonlyArray, -): Pick { - return { - projectIds: projects.map((project) => project.id), - projectById: Object.fromEntries( - projects.map((project) => [project.id, project] as const), - ) as Record, - }; -} - -function getStoredEnvironmentState( - state: AppState, - environmentId: EnvironmentId, -): EnvironmentState { - return state.environmentStateById[environmentId] ?? initialEnvironmentState; -} - -function commitEnvironmentState( - state: AppState, - environmentId: EnvironmentId, - nextEnvironmentState: EnvironmentState, -): AppState { - const currentEnvironmentState = state.environmentStateById[environmentId]; - const environmentStateById = - currentEnvironmentState === nextEnvironmentState - ? state.environmentStateById - : { - ...state.environmentStateById, - [environmentId]: nextEnvironmentState, - }; - - if (environmentStateById === state.environmentStateById) { - return state; - } - - return { - ...state, - environmentStateById, - }; -} - -function syncEnvironmentShellSnapshot( - state: EnvironmentState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): EnvironmentState { - const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); - const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); - let nextState: EnvironmentState = { - ...state, - ...buildProjectState(nextProjects), - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - sidebarThreadSummaryById: {}, - messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), - messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), - activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), - activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), - proposedPlanIdsByThreadId: retainThreadScopedRecord( - state.proposedPlanIdsByThreadId, - nextThreadIds, - ), - proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), - turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), - turnDiffSummaryByThreadId: retainThreadScopedRecord( - state.turnDiffSummaryByThreadId, - nextThreadIds, - ), - bootstrapComplete: true, - }; - - for (const thread of snapshot.threads) { - nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); - } - - return nextState; -} - -export function syncServerShellSnapshot( - state: AppState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createShellSnapshotManager or a shared adapter over its reducer. - return commitEnvironmentState( - state, - environmentId, - syncEnvironmentShellSnapshot( - getStoredEnvironmentState(state, environmentId), - snapshot, - environmentId, - ), - ); -} - -export function syncServerThreadDetail( - state: AppState, - thread: OrchestrationThread, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createThreadDetailManager or a shared adapter over its reducer. - const environmentState = getStoredEnvironmentState(state, environmentId); - const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); - return commitEnvironmentState( - state, - environmentId, - writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), - ); -} - -function applyEnvironmentOrchestrationEvent( - state: EnvironmentState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.type) { - case "project.created": { - const nextProject = mapProject( - { - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - repositoryIdentity: event.payload.repositoryIdentity ?? null, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, - environmentId, - ); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.payload.projectId || - state.projectById[projectId]?.cwd === event.payload.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - - case "project.meta-updated": { - const project = state.projectById[event.payload.projectId]; - if (!project) { - return state; - } - const nextProject: Project = { - ...project, - ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), - ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), - ...(event.payload.repositoryIdentity !== undefined - ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } - : {}), - ...(event.payload.defaultModelSelection !== undefined - ? { - defaultModelSelection: event.payload.defaultModelSelection - ? normalizeModelSelection(event.payload.defaultModelSelection) - : null, - } - : {}), - ...(event.payload.scripts !== undefined - ? { scripts: mapProjectScripts(event.payload.scripts) } - : {}), - updatedAt: event.payload.updatedAt, - }; - return { - ...state, - projectById: { - ...state.projectById, - [event.payload.projectId]: nextProject, - }, - }; - } - - case "project.deleted": { - if (!state.projectById[event.payload.projectId]) { - return state; - } - const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.payload.projectId), - }; - } - - case "thread.created": { - const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); - const nextThread = mapThread( - { - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, - environmentId, - ); - return writeThreadState(state, nextThread, previousThread); - } - - case "thread.deleted": - return removeThreadState(state, event.payload.threadId); - - case "thread.archived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: event.payload.archivedAt, - updatedAt: event.payload.updatedAt, - })); - - case "thread.unarchived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: null, - updatedAt: event.payload.updatedAt, - })); - - case "thread.meta-updated": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } - : {}), - updatedAt: event.payload.updatedAt, - })); - - case "thread.runtime-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - runtimeMode: event.payload.runtimeMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.interaction-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - interactionMode: event.payload.interactionMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.turn-start-requested": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - pendingSourceProposedPlan: event.payload.sourceProposedPlan, - updatedAt: event.occurredAt, - })); - - case "thread.turn-interrupt-requested": { - if (event.payload.turnId === undefined) { - return state; - } - return updateThreadState(state, event.payload.threadId, (thread) => { - const latestTurn = thread.latestTurn; - if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { - return thread; - } - return { - ...thread, - latestTurn: buildLatestTurn({ - previous: latestTurn, - turnId: event.payload.turnId, - state: "interrupted", - requestedAt: latestTurn.requestedAt, - startedAt: latestTurn.startedAt ?? event.payload.createdAt, - completedAt: latestTurn.completedAt ?? event.payload.createdAt, - assistantMessageId: latestTurn.assistantMessageId, - }), - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.message-sent": - return updateThreadState(state, event.payload.threadId, (thread) => { - const message = mapMessage(thread.environmentId, { - id: event.payload.messageId, - role: event.payload.role, - text: event.payload.text, - ...(event.payload.attachments !== undefined - ? { attachments: event.payload.attachments } - : {}), - turnId: event.payload.turnId, - streaming: event.payload.streaming, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - }); - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? thread.messages.map((entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming - ? entry.completedAt !== undefined - ? { completedAt: entry.completedAt } - : {} - : message.completedAt !== undefined - ? { completedAt: message.completedAt } - : {}), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); - const turnDiffSummaries = - event.payload.role === "assistant" && event.payload.turnId !== null - ? rebindTurnDiffSummariesForAssistantMessage( - thread.turnDiffSummaries, - event.payload.turnId, - event.payload.messageId, - ) - : thread.turnDiffSummaries; - // A completed assistant message only settles the turn once the - // session is no longer running it — providers may emit several - // assistant messages per turn (commentary between tool calls), and - // the turn must stay unsettled until the provider reports turn end. - const turnStillRunning = - event.payload.turnId !== null && - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn: Thread["latestTurn"] = - event.payload.role === "assistant" && - event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: settlesTurn - ? thread.latestTurn?.state === "interrupted" - ? "interrupted" - : thread.latestTurn?.state === "error" - ? "error" - : "completed" - : "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - sourceProposedPlan: thread.pendingSourceProposedPlan, - completedAt: settlesTurn - ? event.payload.updatedAt - : thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.completedAt ?? null) - : null, - assistantMessageId: event.payload.messageId, - }) - : thread.latestTurn; - return { - ...thread, - messages: cappedMessages, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.session-set": - return updateThreadState(state, event.payload.threadId, (thread) => { - // Leaving the "running" session status is the turn-end signal: - // settle a still-running latest turn so its duration reflects the - // whole turn, not the last assistant message. - const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); - const latestTurn: Thread["latestTurn"] = - event.payload.session.status === "running" && event.payload.session.activeTurnId !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.session.activeTurnId, - state: "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.requestedAt - : event.payload.session.updatedAt, - startedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) - : event.payload.session.updatedAt, - completedAt: null, - assistantMessageId: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.assistantMessageId - : null, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn !== null && - thread.latestTurn.state === "running" && - settledTurnState !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: thread.latestTurn.turnId, - state: settledTurnState, - requestedAt: thread.latestTurn.requestedAt, - startedAt: thread.latestTurn.startedAt, - // A running turn's completedAt can only hold a mid-turn - // placeholder checkpoint timestamp — the session leaving - // "running" is the authoritative turn end. - completedAt: event.payload.session.updatedAt, - assistantMessageId: thread.latestTurn.assistantMessageId, - }) - : thread.latestTurn; - return { - ...thread, - session: mapSession(event.payload.session), - error: sanitizeThreadErrorMessage(event.payload.session.lastError), - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.session-stop-requested": - return updateThreadState(state, event.payload.threadId, (thread) => - thread.session === null - ? thread - : { - ...thread, - session: { - ...thread.session, - status: "closed", - orchestrationStatus: "stopped", - activeTurnId: undefined, - updatedAt: event.payload.createdAt, - }, - updatedAt: event.occurredAt, - }, - ); - - case "thread.proposed-plan-upserted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const proposedPlan = mapProposedPlan(event.payload.proposedPlan); - const proposedPlans = [ - ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), - proposedPlan, - ] - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(-MAX_THREAD_PROPOSED_PLANS); - return { - ...thread, - proposedPlans, - updatedAt: event.occurredAt, - }; - }); - - case "thread.turn-diff-completed": - return updateThreadState(state, event.payload.threadId, (thread) => { - const checkpoint = mapTurnDiffSummary({ - turnId: event.payload.turnId, - checkpointTurnCount: event.payload.checkpointTurnCount, - checkpointRef: event.payload.checkpointRef, - status: event.payload.status, - files: event.payload.files, - assistantMessageId: event.payload.assistantMessageId, - completedAt: event.payload.completedAt, - }); - const existing = thread.turnDiffSummaries.find( - (entry) => entry.turnId === checkpoint.turnId, - ); - if (existing && existing.status !== "missing" && checkpoint.status === "missing") { - return thread; - } - const turnDiffSummaries = [ - ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), - checkpoint, - ] - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - // Mid-turn diff updates produce placeholder checkpoints; record the - // diff summary, but don't settle a turn its session is still running. - const turnStillRunning = - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const latestTurn = - !turnStillRunning && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: checkpointStatusToLatestTurnState(event.payload.status), - requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, - startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, - assistantMessageId: event.payload.assistantMessageId, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn; - return { - ...thread, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.reverted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const turnDiffSummaries = thread.turnDiffSummaries - .filter( - (entry) => - entry.checkpointTurnCount !== undefined && - entry.checkpointTurnCount <= event.payload.turnCount, - ) - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); - const messages = retainThreadMessagesAfterRevert( - thread.messages, - retainedTurnIds, - event.payload.turnCount, - ).slice(-MAX_THREAD_MESSAGES); - const proposedPlans = retainThreadProposedPlansAfterRevert( - thread.proposedPlans, - retainedTurnIds, - ).slice(-MAX_THREAD_PROPOSED_PLANS); - const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); - const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; - - return { - ...thread, - turnDiffSummaries, - messages, - proposedPlans, - activities, - pendingSourceProposedPlan: undefined, - latestTurn: - latestCheckpoint === null - ? null - : { - turnId: latestCheckpoint.turnId, - state: checkpointStatusToLatestTurnState( - (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", - ), - requestedAt: latestCheckpoint.completedAt, - startedAt: latestCheckpoint.completedAt, - completedAt: latestCheckpoint.completedAt, - assistantMessageId: latestCheckpoint.assistantMessageId ?? null, - }, - updatedAt: event.occurredAt, - }; - }); - - case "thread.activity-appended": - return updateThreadState(state, event.payload.threadId, (thread) => { - const activities = [ - ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), - { ...event.payload.activity }, - ] - .toSorted(compareActivities) - .slice(-MAX_THREAD_ACTIVITIES); - return { - ...thread, - activities, - updatedAt: event.occurredAt, - }; - }); - - case "thread.approval-response-requested": - case "thread.user-input-response-requested": - return state; - } - - return state; -} - -function applyEnvironmentShellEvent( - state: EnvironmentState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.kind) { - case "project-upserted": { - const nextProject = mapProject(event.project, environmentId); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.project.id || - state.projectById[projectId]?.cwd === event.project.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - case "project-removed": { - if (!state.projectById[event.projectId]) { - return state; - } - const { [event.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.projectId), - }; - } - case "thread-upserted": - return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); - case "thread-removed": - return removeThreadState(state, event.threadId); - } -} - -export function applyOrchestrationEvents( - state: AppState, - events: ReadonlyArray, - environmentId: EnvironmentId, -): AppState { - if (events.length === 0) { - return state; - } - const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); - const nextEnvironmentState = events.reduce( - (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), - currentEnvironmentState, - ); - return commitEnvironmentState(state, environmentId, nextEnvironmentState); -} - -function getEnvironmentEntries( - state: AppState, -): ReadonlyArray { - return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< - readonly [EnvironmentId, EnvironmentState] - >; -} - -export function selectEnvironmentState( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): EnvironmentState { - return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; -} - -export function selectProjectsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Project[] { - return getProjects(selectEnvironmentState(state, environmentId)); -} - -export function selectThreadsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Thread[] { - return getThreads(selectEnvironmentState(state, environmentId)); -} - -export function selectProjectsAcrossEnvironments(state: AppState): Project[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getProjects(environmentState), - ); -} - -export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getThreads(environmentState), - ); -} - -/** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ -export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const shell = environmentState.threadShellById[threadId]; - return shell ? [shell] : []; - }), - ); -} - -export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { - return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread && thread.environmentId === environmentId ? [thread] : []; - }), - ); -} - -export function selectSidebarThreadsForProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): SidebarThreadSummary[] { - if (!ref) { - return []; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; - return threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread ? [thread] : []; - }); -} - -export function selectSidebarThreadsForProjectRefs( - state: AppState, - refs: readonly ScopedProjectRef[], -): SidebarThreadSummary[] { - if (refs.length === 0) return []; - if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); - return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); -} - -export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { - return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; -} - -export function selectProjectByRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): Project | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] - : undefined; -} - -export function selectThreadByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): Thread | undefined { - return ref - ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) - : undefined; -} - -export function selectThreadExistsByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): boolean { - return ref - ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined - : false; -} - -export function selectSidebarThreadSummaryByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): SidebarThreadSummary | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] - : undefined; -} - -export function selectThreadIdsByProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): ThreadId[] { - return ref - ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? - EMPTY_THREAD_IDS) - : EMPTY_THREAD_IDS; -} - -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - if (state.activeEnvironmentId === null) { - return state; - } - - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, state.activeEnvironmentId), - threadId, - (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; - }, - ); - return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); -} - -export function applyOrchestrationEvent( - state: AppState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentOrchestrationEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function applyShellEvent( - state: AppState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentShellEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { - if (state.activeEnvironmentId === environmentId) { - return state; - } - - return { - ...state, - activeEnvironmentId: environmentId, - }; -} - -export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState { - if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) { - return state; - } - - const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; - return { - ...state, - activeEnvironmentId: - state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, - environmentStateById, - }; -} - -export function setThreadBranch( - state: AppState, - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, -): AppState { - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, threadRef.environmentId), - threadRef.threadId, - (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }, - ); - return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); -} - -interface AppStore extends AppState { - setActiveEnvironmentId: (environmentId: EnvironmentId) => void; - removeEnvironmentState: (environmentId: EnvironmentId) => void; - syncServerShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; - applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; - applyOrchestrationEvents: ( - events: ReadonlyArray, - environmentId: EnvironmentId, - ) => void; - applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; - setError: (threadId: ThreadId, error: string | null) => void; - setThreadBranch: ( - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, - ) => void; -} - -export const useStore = create((set) => ({ - ...initialState, - setActiveEnvironmentId: (environmentId) => - set((state) => setActiveEnvironmentId(state, environmentId)), - removeEnvironmentState: (environmentId) => - set((state) => removeEnvironmentState(state, environmentId)), - syncServerShellSnapshot: (snapshot, environmentId) => - set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), - syncServerThreadDetail: (thread, environmentId) => - set((state) => syncServerThreadDetail(state, thread, environmentId)), - applyOrchestrationEvent: (event, environmentId) => - set((state) => applyOrchestrationEvent(state, event, environmentId)), - applyOrchestrationEvents: (events, environmentId) => - set((state) => applyOrchestrationEvents(state, events, environmentId)), - applyShellEvent: (event, environmentId) => - set((state) => applyShellEvent(state, event, environmentId)), - setError: (threadId, error) => set((state) => setError(state, threadId, error)), - setThreadBranch: (threadRef, branch, worktreePath) => - set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), -})); diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts deleted file mode 100644 index 95ed6ff1f41..00000000000 --- a/apps/web/src/storeSelectors.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; -import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { type Project, type Thread } from "./types"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; - -export function createProjectSelectorByRef( - ref: ScopedProjectRef | null | undefined, -): (state: AppState) => Project | undefined { - return (state) => - ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; -} - -function createScopedThreadSelector( - resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - let previousEnvironmentState: EnvironmentState | undefined; - let previousThreadId: ThreadId | undefined; - let previousThread: Thread | undefined; - - return (state) => { - const ref = resolveRef(state); - if (!ref) { - return undefined; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - if ( - previousThread && - previousEnvironmentState === environmentState && - previousThreadId === ref.threadId - ) { - return previousThread; - } - - previousEnvironmentState = environmentState; - previousThreadId = ref.threadId; - previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); - return previousThread; - }; -} - -export function createThreadSelectorByRef( - ref: ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector(() => ref); -} - -export function createThreadSelectorAcrossEnvironments( - threadId: ThreadId | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector((state) => { - if (!threadId) { - return undefined; - } - - for (const [environmentId, environmentState] of Object.entries( - state.environmentStateById, - ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { - if (environmentState.threadShellById[threadId]) { - return { - environmentId, - threadId, - }; - } - } - return undefined; - }); -} diff --git a/apps/web/src/terminalSessionState.ts b/apps/web/src/terminalSessionState.ts deleted file mode 100644 index 106a16f8fd7..00000000000 --- a/apps/web/src/terminalSessionState.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - EMPTY_TERMINAL_ID_LIST_ATOM, - EMPTY_TERMINAL_SESSION_ATOM, - createTerminalSessionManager, - getKnownTerminalSessionTarget, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSession, - type TerminalSessionTarget, - type TerminalSessionState, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; - -import { appAtomRegistry } from "./rpc/atomRegistry"; - -export const terminalSessionManager = createTerminalSessionManager({ - getRegistry: () => appAtomRegistry, -}); - -export function subscribeTerminalMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; -}) { - 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?: Parameters[0]["onEvent"]; -}) { - return terminalSessionManager.attach({ - environmentId: input.environmentId, - client: input.client, - terminal: input.terminal, - ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), - ...(input.onEvent ? { onEvent: input.onEvent } : {}), - }); -} - -export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { - const target = getKnownTerminalSessionTarget(input); - return useAtomValue( - target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, - ); -} - -export function useKnownTerminalSessions(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - ); -} - -export function useThreadRunningTerminalIds(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM, - ); -} diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index c4d4e9ff8a6..b0b1df96e1f 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, }); }); @@ -261,6 +262,29 @@ describe("terminalUiStateStore actions", () => { ]); }); + it("does not import a closed panel terminal from stale metadata", () => { + const store = useTerminalUiStateStore.getState(); + store.newTerminal(THREAD_REF, "term-2"); + store.closeTerminal(THREAD_REF, "term-1"); + + store.reconcileTerminalIds(THREAD_REF, ["term-1", "term-2"]); + + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ).terminalIds, + ).toEqual(["term-2"]); + + store.newTerminal(THREAD_REF, "term-1"); + expect( + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ).terminalIds, + ).toEqual(["term-2", "term-1"]); + }); + it("is a no-op when clearing terminal UI state for a thread with no state", () => { const store = useTerminalUiStateStore.getState(); const before = useTerminalUiStateStore.getState(); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index e5262bfcf7c..290ca8e5954 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -519,8 +519,51 @@ function updateTerminalUiStateByThreadKey( }; } +function updateSuppressedTerminalId( + suppressedTerminalIdsByThreadKey: Record, + threadRef: ScopedThreadRef, + terminalId: string, + suppressed: boolean, +): Record { + const normalizedTerminalId = terminalId.trim(); + if (normalizedTerminalId.length === 0) { + return suppressedTerminalIdsByThreadKey; + } + const threadKey = terminalThreadKey(threadRef); + const currentIds = suppressedTerminalIdsByThreadKey[threadKey] ?? []; + const currentlySuppressed = currentIds.includes(normalizedTerminalId); + if (currentlySuppressed === suppressed) { + return suppressedTerminalIdsByThreadKey; + } + if (suppressed) { + return { + ...suppressedTerminalIdsByThreadKey, + [threadKey]: [...currentIds, normalizedTerminalId], + }; + } + + const remainingIds = currentIds.filter((id) => id !== normalizedTerminalId); + if (remainingIds.length > 0) { + return { + ...suppressedTerminalIdsByThreadKey, + [threadKey]: remainingIds, + }; + } + return removeRecordEntry(suppressedTerminalIdsByThreadKey, threadKey); +} + +function removeRecordEntry(record: Record, key: string): Record { + if (record[key] === undefined) { + return record; + } + const { [key]: _removed, ...remaining } = record; + return remaining; +} + interface TerminalUiStateStoreState { terminalUiStateByThreadKey: Record; + /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ + suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; @@ -541,106 +584,186 @@ interface TerminalUiStateStoreState { export const useTerminalUiStateStore = create()( persist( - (set) => { + (set, get) => { const updateTerminal = ( threadRef: ScopedThreadRef, - updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, + updater: ( + state: ThreadTerminalUiState, + suppressedTerminalIds: readonly string[], + ) => ThreadTerminalUiState, + suppression?: { terminalId: string; suppressed: boolean }, ) => { set((state) => { + const threadKey = terminalThreadKey(threadRef); + const suppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] ?? []; const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( state.terminalUiStateByThreadKey, threadRef, - updater, + (terminalState) => updater(terminalState, suppressedTerminalIds), ); - if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { + const nextSuppressedTerminalIdsByThreadKey = suppression + ? updateSuppressedTerminalId( + state.suppressedTerminalIdsByThreadKey, + threadRef, + suppression.terminalId, + suppression.suppressed, + ) + : state.suppressedTerminalIdsByThreadKey; + if ( + nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey + ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, - setTerminalOpen: (threadRef, open) => - updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), + suppressedTerminalIdsByThreadKey: {}, + setTerminalOpen: (threadRef, open) => { + const terminalState = selectThreadTerminalUiState( + get().terminalUiStateByThreadKey, + threadRef, + ); + updateTerminal( + threadRef, + (state) => setThreadTerminalOpen(state, open), + open && terminalState.terminalIds.length === 0 + ? { terminalId: DEFAULT_THREAD_TERMINAL_ID, suppressed: false } + : undefined, + ); + }, setTerminalHeight: (threadRef, height) => updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId), { + terminalId, + suppressed: false, + }), splitTerminalVertical: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical"), { + terminalId, + suppressed: false, + }), newTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), - ensureTerminal: (threadRef, terminalId, options) => - updateTerminal(threadRef, (state) => { - let nextState = state; - if (!state.terminalIds.includes(terminalId)) { - nextState = newThreadTerminal(nextState, terminalId); - } - if (options?.active === false) { - nextState = { - ...nextState, - activeTerminalId: state.activeTerminalId, - activeTerminalGroupId: state.activeTerminalGroupId, - }; - } - if (options?.active ?? true) { - nextState = setThreadActiveTerminal(nextState, terminalId); - } - if (options?.open) { - nextState = setThreadTerminalOpen(nextState, true); - } - return normalizeThreadTerminalUiState(nextState); + updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId), { + terminalId, + suppressed: false, }), + ensureTerminal: (threadRef, terminalId, options) => + updateTerminal( + threadRef, + (state) => { + let nextState = state; + if (!state.terminalIds.includes(terminalId)) { + nextState = newThreadTerminal(nextState, terminalId); + } + if (options?.active === false) { + nextState = { + ...nextState, + activeTerminalId: state.activeTerminalId, + activeTerminalGroupId: state.activeTerminalGroupId, + }; + } + if (options?.active ?? true) { + nextState = setThreadActiveTerminal(nextState, terminalId); + } + if (options?.open) { + nextState = setThreadTerminalOpen(nextState, true); + } + return normalizeThreadTerminalUiState(nextState); + }, + { terminalId, suppressed: false }, + ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadRef, terminalId) => - updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), + updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { + terminalId, + suppressed: true, + }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), + updateTerminal(threadRef, (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }), clearTerminalUiState: (threadRef) => set((state) => { + const threadKey = terminalThreadKey(threadRef); const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( state.terminalUiStateByThreadKey, threadRef, () => createDefaultThreadTerminalUiState(), ); - if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { + const hadSuppressedTerminalIds = + state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + if ( + nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && + !hadSuppressedTerminalIds + ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: removeRecordEntry( + state.suppressedTerminalIdsByThreadKey, + threadKey, + ), }; }), removeTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState) { + const hadSuppressedTerminalIds = + state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + if (!hadTerminalUiState && !hadSuppressedTerminalIds) { return state; } - const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; - delete nextTerminalUiStateByThreadKey[threadKey]; return { - terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalUiStateByThreadKey: removeRecordEntry( + state.terminalUiStateByThreadKey, + threadKey, + ), + suppressedTerminalIdsByThreadKey: removeRecordEntry( + state.suppressedTerminalIdsByThreadKey, + threadKey, + ), }; }), removeOrphanedTerminalUiStates: (activeThreadKeys) => set((state) => { - const orphanedIds = Object.keys(state.terminalUiStateByThreadKey).filter( - (key) => !activeThreadKeys.has(key), + const orphanedIds = new Set( + [ + ...Object.keys(state.terminalUiStateByThreadKey), + ...Object.keys(state.suppressedTerminalIdsByThreadKey), + ].filter((key) => !activeThreadKeys.has(key)), ); - if (orphanedIds.length === 0) { + if (orphanedIds.size === 0) { return state; } - const next = { ...state.terminalUiStateByThreadKey }; + const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; + const nextSuppressedTerminalIdsByThreadKey = { + ...state.suppressedTerminalIdsByThreadKey, + }; for (const id of orphanedIds) { - delete next[id]; + delete nextTerminalUiStateByThreadKey[id]; + delete nextSuppressedTerminalIdsByThreadKey[id]; } return { - terminalUiStateByThreadKey: next, + terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), }; diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts deleted file mode 100644 index 0766f0c8e13..00000000000 --- a/apps/web/src/threadDerivation.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; -import type { EnvironmentState } from "./store"; -import type { - ChatMessage, - ProposedPlan, - Thread, - ThreadSession, - ThreadShell, - ThreadTurnState, - TurnDiffSummary, -} from "./types"; - -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: Thread["activities"] = []; -const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; -const EMPTY_MESSAGE_MAP: Record = {}; -const EMPTY_ACTIVITY_MAP: Record = {}; -const EMPTY_PROPOSED_PLAN_MAP: Record = {}; -const EMPTY_TURN_DIFF_MAP: Record = {}; - -const collectedByIdsCache = new WeakMap>(); -const threadCache = new WeakMap< - ThreadShell, - { - session: ThreadSession | null; - turnState: ThreadTurnState | undefined; - messages: Thread["messages"]; - activities: Thread["activities"]; - proposedPlans: Thread["proposedPlans"]; - turnDiffSummaries: Thread["turnDiffSummaries"]; - thread: Thread; - } ->(); - -function collectByIds( - ids: readonly TKey[] | undefined, - byId: Record | undefined, - emptyValue: TValue[], -): TValue[] { - if (!ids || ids.length === 0 || !byId) { - return emptyValue; - } - - const cachedByRecord = collectedByIdsCache.get(ids); - const cached = cachedByRecord?.get(byId); - if (cached) { - return cached as TValue[]; - } - - const nextValues = ids.flatMap((id) => { - const value = byId[id]; - return value ? [value] : []; - }); - const nextCachedByRecord = cachedByRecord ?? new WeakMap(); - nextCachedByRecord.set(byId, nextValues); - if (!cachedByRecord) { - collectedByIdsCache.set(ids, nextCachedByRecord); - } - return nextValues; -} - -function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { - return collectByIds( - state.messageIdsByThreadId[threadId], - state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, - EMPTY_MESSAGES, - ); -} - -function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { - return collectByIds( - state.activityIdsByThreadId[threadId], - state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, - EMPTY_ACTIVITIES, - ); -} - -function selectThreadProposedPlans( - state: EnvironmentState, - threadId: ThreadId, -): Thread["proposedPlans"] { - return collectByIds( - state.proposedPlanIdsByThreadId[threadId], - state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, - EMPTY_PROPOSED_PLANS, - ); -} - -function selectThreadTurnDiffSummaries( - state: EnvironmentState, - threadId: ThreadId, -): Thread["turnDiffSummaries"] { - return collectByIds( - state.turnDiffIdsByThreadId[threadId], - state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, - EMPTY_TURN_DIFF_SUMMARIES, - ); -} - -export function getThreadFromEnvironmentState( - state: EnvironmentState, - threadId: ThreadId, -): Thread | undefined { - const shell = state.threadShellById[threadId]; - if (!shell) { - return undefined; - } - - const session = state.threadSessionById[threadId] ?? null; - const turnState = state.threadTurnStateById[threadId]; - const messages = selectThreadMessages(state, threadId); - const activities = selectThreadActivities(state, threadId); - const proposedPlans = selectThreadProposedPlans(state, threadId); - const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); - const cached = threadCache.get(shell); - - if ( - cached && - cached.session === session && - cached.turnState === turnState && - cached.messages === messages && - cached.activities === activities && - cached.proposedPlans === proposedPlans && - cached.turnDiffSummaries === turnDiffSummaries - ) { - return cached.thread; - } - - const thread: Thread = { - ...shell, - session, - latestTurn: turnState?.latestTurn ?? null, - pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, - messages, - activities, - proposedPlans, - turnDiffSummaries, - }; - - threadCache.set(shell, { - session, - turnState, - messages, - activities, - proposedPlans, - turnDiffSummaries, - thread, - }); - - return thread; -} diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index e5a365b0889..d15a233a304 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 3fda9eb4235..19a7d5ca603 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d508e3c6010..45a8539a151 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,22 +1,20 @@ import type { - EnvironmentId, - ModelSelection, + ChatImageAttachment as ContractChatImageAttachment, + OrchestrationCheckpointFile, + OrchestrationCheckpointSummary, OrchestrationLatestTurn, - OrchestrationProposedPlanId, - RepositoryIdentity, - OrchestrationSessionStatus, - OrchestrationThreadActivity, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, ProjectScript as ContractProjectScript, - ThreadId, - ProjectId, - TurnId, - MessageId, - ProviderDriverKind, - ProviderInstanceId, - CheckpointRef, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -33,139 +31,27 @@ export interface ThreadTerminalGroup { splitDirection?: "horizontal" | "vertical"; } -export interface ChatImageAttachment { - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - previewUrl?: string; +export interface ChatImageAttachment extends ContractChatImageAttachment { + readonly previewUrl?: string; } export type ChatAttachment = ChatImageAttachment; -export interface ChatMessage { - id: MessageId; - role: "user" | "assistant" | "system"; - text: string; - attachments?: ChatAttachment[]; - turnId?: TurnId | null; - createdAt: string; - completedAt?: string | undefined; - streaming: boolean; +export interface ChatMessage extends Omit { + readonly attachments?: ReadonlyArray | undefined; } -export interface ProposedPlan { - id: OrchestrationProposedPlanId; - turnId: TurnId | null; - planMarkdown: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - createdAt: string; - updatedAt: string; -} +export type ProposedPlan = OrchestrationProposedPlan; +export type TurnDiffFileChange = OrchestrationCheckpointFile; +export type TurnDiffSummary = OrchestrationCheckpointSummary; -export interface TurnDiffFileChange { - path: string; - kind?: string | undefined; - additions?: number | undefined; - deletions?: number | undefined; -} - -export interface TurnDiffSummary { - turnId: TurnId; - completedAt: string; - status?: string | undefined; - files: TurnDiffFileChange[]; - checkpointRef?: CheckpointRef | undefined; - assistantMessageId?: MessageId | undefined; - checkpointTurnCount?: number | undefined; -} - -export interface Project { - id: ProjectId; - environmentId: EnvironmentId; - name: string; - cwd: string; - repositoryIdentity?: RepositoryIdentity | null; - defaultModelSelection: ModelSelection | null; - createdAt?: string | undefined; - updatedAt?: string | undefined; - scripts: ProjectScript[]; -} - -export interface Thread { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - messages: ChatMessage[]; - proposedPlans: ProposedPlan[]; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; - branch: string | null; - worktreePath: string | null; - turnDiffSummaries: TurnDiffSummary[]; - activities: OrchestrationThreadActivity[]; -} - -export interface ThreadShell { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - branch: string | null; - worktreePath: string | null; -} +export type Project = EnvironmentProject; +export type Thread = EnvironmentThread; +export type ThreadShell = EnvironmentThreadShell; export interface ThreadTurnState { latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; } -export interface SidebarThreadSummary { - id: ThreadId; - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - branch: string | null; - worktreePath: string | null; - latestUserMessageAt: string | null; - hasPendingApprovals: boolean; - hasPendingUserInput: boolean; - hasActionableProposedPlan: boolean; -} - -export interface ThreadSession { - provider: ProviderDriverKind; - providerInstanceId?: ProviderInstanceId | undefined; - status: SessionPhase | "error" | "closed"; - activeTurnId?: TurnId | undefined; - createdAt: string; - updatedAt: string; - lastError?: string; - orchestrationStatus: OrchestrationSessionStatus; -} +export type SidebarThreadSummary = EnvironmentThreadShell; +export type ThreadSession = OrchestrationSession; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c6f445b0c32..0fbbd79ec27 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -2,19 +2,18 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - clearThreadUi, - hydratePersistedProjectState, - markThreadVisited, + legacyProjectCwdPreferenceKey, markThreadUnread, + markThreadVisited, + parsePersistedState, PERSISTED_STATE_KEY, type PersistedUiState, persistState, reorderProjects, + resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, - syncProjects, - syncThreads, type UiState, } from "./uiStateStore"; @@ -30,418 +29,187 @@ function makeUiState(overrides: Partial = {}): UiState { } describe("uiStateStore pure functions", () => { - it("markThreadVisited stores the provided server timestamp", () => { + it("stores server timestamps without moving visit state backwards", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState(); + const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); - - expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); - }); - - it("markThreadVisited does not move visit state backwards under clock skew", () => { - const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:30:00.700Z", - }, - }); - - const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.000Z"); - - expect(next).toBe(initialState); + expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); + expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited); + expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); - it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { + it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); - const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeUiState({ threadLastVisitedAtById: { [threadId]: "2026-02-25T12:35:00.000Z", }, }); - const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); + const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z"); expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); + expect(markThreadUnread(next, threadId, null)).toBe(next); }); - it("markThreadUnread does not change a thread without a completed turn", () => { - const threadId = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadLastVisitedAtById: { - [threadId]: "2026-02-25T12:35:00.000Z", - }, - }); + it("resolves project expansion from logical, physical, and legacy preference keys", () => { + const physicalKey = "environment:/repo/project"; + const legacyKey = legacyProjectCwdPreferenceKey("/repo/project"); - const next = markThreadUnread(initialState, threadId, null); - - expect(next).toBe(initialState); + expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe( + false, + ); + expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe( + false, + ); + expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false); + expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true); }); - it("reorderProjects moves a project to a target index", () => { - const project1 = ProjectId.make("project-1"); - const project2 = ProjectId.make("project-2"); - const project3 = ProjectId.make("project-3"); - const initialState = makeUiState({ - projectOrder: [project1, project2, project3], - }); + it("sets expansion for every stable key belonging to a logical project", () => { + const initialState = makeUiState(); + const keys = ["logical", "environment-a:/repo", "environment-b:/repo"]; - const next = reorderProjects(initialState, [project1], [project3]); + const next = setProjectExpanded(initialState, keys, false); - expect(next.projectOrder).toEqual([project2, project3, project1]); + expect(next.projectExpandedById).toEqual({ + logical: false, + "environment-a:/repo": false, + "environment-b:/repo": false, + }); + expect(setProjectExpanded(next, keys, false)).toBe(next); }); - it("reorderProjects is a no-op when dragged key is not in projectOrder", () => { + it("reorders from the current atom-derived project order", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); - const initialState = makeUiState({ - projectOrder: [project1, project2], - }); - - const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]); - - expect(next).toBe(initialState); - }); - - it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => { - const initialState = makeUiState(); + const project3 = ProjectId.make("project-3"); + const currentOrder = [project1, project2, project3]; - const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http"); + const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]); - expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); - expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); - expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ - defaultAdvertisedEndpointKey: null, - }); + expect(next.projectOrder).toEqual([project2, project3, project1]); }); - it("reorderProjects moves all member keys of a multi-member group together", () => { + it("moves grouped project members together", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const keyB = "env-local:proj-b"; const keyC = "env-local:proj-c"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyB, keyC], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); - - expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); - }); - - it("reorderProjects handles member keys scattered across projectOrder", () => { - const keyALocal = "env-local:proj-a"; - const keyB = "env-local:proj-b"; - const keyARemote = "env-remote:proj-a"; - const keyC = "env-local:proj-c"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyB, keyARemote, keyC], - }); + const currentOrder = [keyALocal, keyARemote, keyB, keyC]; - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); + const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); }); - it("reorderProjects places group after target when dragged from before a non-last target", () => { - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const keyB = "env-local:proj-b"; - const keyC = "env-local:proj-c"; - const keyD = "env-local:proj-d"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); + it("does not reorder missing or identical groups", () => { + const currentOrder = ["env-local:proj-a", "env-local:proj-b"]; + const state = makeUiState(); - expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]); + expect(reorderProjects(state, currentOrder, ["env-local:missing"], ["env-local:proj-b"])).toBe( + state, + ); + expect(reorderProjects(state, currentOrder, ["env-local:proj-a"], ["env-local:proj-a"])).toBe( + state, + ); }); - it("reorderProjects places group before target when dragged from after", () => { - const keyB = "env-local:proj-b"; - const keyC = "env-local:proj-c"; - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const initialState = makeUiState({ - projectOrder: [keyB, keyC, keyALocal, keyARemote], - }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]); - - expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]); - }); + it("stores only collapsed changed-file turns", () => { + const threadId = ThreadId.make("thread-1"); + const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); - it("reorderProjects with multi-member target inserts after first target occurrence", () => { - const keyALocal = "env-local:proj-a"; - const keyARemote = "env-remote:proj-a"; - const keyBLocal = "env-local:proj-b"; - const keyBRemote = "env-remote:proj-b"; - const initialState = makeUiState({ - projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote], + expect(collapsed.threadChangedFilesExpandedById).toEqual({ + [threadId]: { + "turn-1": false, + }, }); - - const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]); - - // Target members may become non-contiguous; this is fine because the - // sidebar groups by logical key using first-occurrence positioning. - expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]); + expect( + setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) + .threadChangedFilesExpandedById, + ).toEqual({}); }); - it("reorderProjects is a no-op when dragged group equals target group", () => { - const key1 = "env-local:proj-a"; - const key2 = "env-remote:proj-a"; - const initialState = makeUiState({ - projectOrder: [key1, key2, "env-local:proj-b"], - }); - - const next = reorderProjects(initialState, [key1, key2], [key1, key2]); + it("stores the endpoint preference by stable key", () => { + const next = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); - expect(next).toBe(initialState); - }); - - it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => { - const initialState = makeUiState({ - projectOrder: ["env-local:proj-a", "env-local:proj-b"], + expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); + expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); + expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ + defaultAdvertisedEndpointKey: null, }); - - const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]); - - expect(next).toBe(initialState); }); +}); - it("syncProjects preserves current project order during snapshot recovery", () => { - const project1 = ProjectId.make("project-1"); - const project2 = ProjectId.make("project-2"); - const project3 = ProjectId.make("project-3"); - const initialState = makeUiState({ +describe("parsePersistedState", () => { + it("hydrates raw UI-owned state without server entities", () => { + const parsed = parsePersistedState({ projectExpandedById: { - [project1]: true, - [project2]: false, + logical: false, + invalid: "no" as unknown as boolean, }, - projectOrder: [project2, project1], - }); - - const next = syncProjects(initialState, [ - { key: project1, logicalKey: project1, cwd: "/tmp/project-1" }, - { key: project2, logicalKey: project2, cwd: "/tmp/project-2" }, - { key: project3, logicalKey: project3, cwd: "/tmp/project-3" }, - ]); - - expect(next.projectOrder).toEqual([project2, project1, project3]); - expect(next.projectExpandedById[project2]).toBe(false); - }); - - it("syncProjects preserves manual order across project id churn at the same cwd", () => { - // Under the current design, physical key and logical key are both - // cwd-derived, so an internal project-id change doesn't alter the store - // keys. This test locks in that stability: re-syncing the same cwds keeps - // manual order and collapse state. - const keyProject1 = "env-local:/tmp/project-1"; - const keyProject2 = "env-local:/tmp/project-2"; - const initialState = syncProjects( - makeUiState({ - projectExpandedById: { - [keyProject1]: true, - [keyProject2]: false, - }, - projectOrder: [keyProject2, keyProject1], - }), - [ - { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, - { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, - ], - ); - - const next = syncProjects(initialState, [ - { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, - { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, - ]); - - expect(next.projectOrder).toEqual([keyProject2, keyProject1]); - expect(next.projectExpandedById[keyProject2]).toBe(false); - }); - - it("syncProjects returns a new state when only project cwd changes", () => { - const project1 = ProjectId.make("project-1"); - const initialState = syncProjects( - makeUiState({ - projectExpandedById: { - [project1]: false, - }, - projectOrder: [project1], - }), - [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], - ); - - const next = syncProjects(initialState, [ - { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" }, - ]); - - expect(next).not.toBe(initialState); - expect(next.projectOrder).toEqual([project1]); - expect(next.projectExpandedById[project1]).toBe(false); - }); - - it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => { - // In repository grouping mode, multiple physical projects (different - // environments or different repo-relative paths) collapse into one - // logical group. The group's expand state must be keyed by the logical - // key so clicks on the grouped row toggle the shared state, and so the - // state survives subsequent syncProjects calls (which rebuild the map - // from incoming inputs). - const physicalLocal = "env-local:/repo/project"; - const physicalRemote = "env-remote:/repo/project"; - const logicalKey = "repo-canonical-key"; - - const initial = syncProjects(makeUiState(), [ - { key: physicalLocal, logicalKey, cwd: "/repo/project" }, - { key: physicalRemote, logicalKey, cwd: "/repo/project" }, - ]); - - expect(initial.projectExpandedById).toEqual({ [logicalKey]: true }); - - const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } }; - const next = syncProjects(afterCollapse, [ - { key: physicalLocal, logicalKey, cwd: "/repo/project" }, - { key: physicalRemote, logicalKey, cwd: "/repo/project" }, - ]); - - expect(next.projectExpandedById[logicalKey]).toBe(false); - }); - - it("syncProjects preserves expand state when a project's logical key changes", () => { - // Example: late-arriving repo metadata flips grouping identity from the - // physical key to a canonical repository key. The row did not actually - // change, so the user's collapse choice must carry over. - const physicalKey = "env-local:/repo/project"; - const previousLogicalKey = physicalKey; - const nextLogicalKey = "repo-canonical-key"; - - const initial = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" }, - ]); - - expect(initial.projectExpandedById[previousLogicalKey]).toBe(true); - - const afterCollapse = { - ...initial, - projectExpandedById: { [previousLogicalKey]: false }, - }; - const next = syncProjects(afterCollapse, [ - { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" }, - ]); - - expect(next.projectExpandedById[nextLogicalKey]).toBe(false); - }); - - it("syncThreads prunes missing thread UI state", () => { - const thread1 = ThreadId.make("thread-1"); - const thread2 = ThreadId.make("thread-2"); - const initialState = makeUiState({ + projectOrder: ["physical-b", "", "physical-a", "physical-b"], threadLastVisitedAtById: { - [thread1]: "2026-02-25T12:35:00.000Z", - [thread2]: "2026-02-25T12:36:00.000Z", + "environment:thread-1": "2026-02-25T12:35:00.000Z", + invalid: "not-a-date", }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { - [thread1]: { + "environment:thread-1": { "turn-1": false, + "turn-2": true, }, - [thread2]: { - "turn-2": false, - }, - }, - }); - - const next = syncThreads(initialState, [{ key: thread1 }]); - - expect(next.threadLastVisitedAtById).toEqual({ - [thread1]: "2026-02-25T12:35:00.000Z", - }); - expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, - }); - }); - - it("syncThreads seeds visit state for unseen snapshot threads", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState(); - - const next = syncThreads(initialState, [ - { - key: thread1, - seedVisitedAt: "2026-02-25T12:35:00.000Z", }, - ]); - - expect(next.threadLastVisitedAtById).toEqual({ - [thread1]: "2026-02-25T12:35:00.000Z", }); - }); - it("setProjectExpanded updates expansion without touching order", () => { - const project1 = ProjectId.make("project-1"); - const initialState = makeUiState({ + expect(parsed).toEqual({ projectExpandedById: { - [project1]: true, + logical: false, }, - projectOrder: [project1], - }); - - const next = setProjectExpanded(initialState, project1, false); - - expect(next.projectExpandedById[project1]).toBe(false); - expect(next.projectOrder).toEqual([project1]); - }); - - it("clearThreadUi removes visit state for deleted threads", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState({ + projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { - [thread1]: "2026-02-25T12:35:00.000Z", + "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { - [thread1]: { + "environment:thread-1": { "turn-1": false, }, }, }); - - const next = clearThreadUi(initialState, thread1); - - expect(next.threadLastVisitedAtById).toEqual({}); - expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState(); - - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); - - expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, + it("migrates legacy CWD project preferences into local alias keys", () => { + const parsed = parsePersistedState({ + collapsedProjectCwds: ["/repo/b"], + expandedProjectCwds: ["/repo/a"], + projectOrderCwds: ["/repo/b", "/repo/a"], }); + const projectAKey = legacyProjectCwdPreferenceKey("/repo/a"); + const projectBKey = legacyProjectCwdPreferenceKey("/repo/b"); + + expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]); + expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true); + expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false); + expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true); }); - it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { - const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState({ - threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, - }, + it("preserves legacy expanded-only semantics for one-way migration", () => { + const parsed = parsePersistedState({ + expandedProjectCwds: ["/repo/a"], }); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); - - expect(next.threadChangedFilesExpandedById).toEqual({}); + expect( + resolveProjectExpanded(parsed.projectExpandedById, [ + legacyProjectCwdPreferenceKey("/repo/a"), + ]), + ).toBe(true); + expect( + resolveProjectExpanded(parsed.projectExpandedById, [ + legacyProjectCwdPreferenceKey("/repo/b"), + ]), + ).toBe(false); }); }); @@ -465,146 +233,77 @@ function createLocalStorageStub(): Storage { }; } -describe("uiStateStore persistence round-trip", () => { +describe("uiStateStore persistence", () => { let localStorageStub: Storage; beforeEach(() => { localStorageStub = createLocalStorageStub(); vi.stubGlobal("window", { localStorage: localStorageStub }); vi.stubGlobal("localStorage", localStorageStub); - // Reset module-level persistence state so tests don't bleed into each other. - hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] }); }); afterEach(() => { vi.unstubAllGlobals(); }); - it("preserves all-collapsed project state across restart", () => { - // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so - // an empty array on rehydrate was indistinguishable from a fresh install - // and the syncProjects fallback re-expanded every row. - const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; - const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; - - let state = syncProjects(makeUiState(), [projectA, projectB]); - state = setProjectExpanded(state, projectA.key, false); - state = setProjectExpanded(state, projectB.key, false); - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - hydratePersistedProjectState(persisted); - const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); - - expect(rehydrated.projectExpandedById).toEqual({ - [projectA.key]: false, - [projectB.key]: false, + it("persists raw UI preferences including thread visit markers", () => { + const state = makeUiState({ + projectExpandedById: { + logical: false, + }, + projectOrder: ["physical-b", "physical-a"], + threadLastVisitedAtById: { + "environment:thread-1": "2026-02-25T12:35:00.000Z", + }, + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + "turn-2": true, + }, + }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", }); - }); - - it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => { - const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; - const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; - const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" }; - let state = syncProjects(makeUiState(), [projectA, projectB]); - state = setProjectExpanded(state, projectB.key, false); persistState(state); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; - hydratePersistedProjectState(persisted); - const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); - - expect(rehydrated.projectExpandedById).toEqual({ - [projectA.key]: true, - [projectB.key]: false, - [projectC.key]: true, - }); - }); - - it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => { - // Pre-fix shape only stored expandedProjectCwds. Absence of - // collapsedProjectCwds opts the session into the legacy fallback so - // upgrade users do not see previously collapsed rows pop open. - hydratePersistedProjectState({ - expandedProjectCwds: ["/projA"], + expect(persisted).toEqual({ + projectExpandedById: { + logical: false, + }, + projectOrder: ["physical-b", "physical-a"], + threadLastVisitedAtById: { + "environment:thread-1": "2026-02-25T12:35:00.000Z", + }, + defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + }, + }, }); - - const rehydrated = syncProjects(makeUiState(), [ - { key: "kA", logicalKey: "kA", cwd: "/projA" }, - { key: "kB", logicalKey: "kB", cwd: "/projB" }, - ]); - - expect(rehydrated.projectExpandedById).toEqual({ - kA: true, - kB: false, + expect(parsePersistedState(persisted)).toEqual({ + ...state, + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + }, + }, }); }); - it("preserves manual project order across restart", () => { - const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" }; - const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" }; - const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" }; - - let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); - state = reorderProjects(state, [projectC.key], [projectA.key]); - expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]); - - hydratePersistedProjectState(persisted); - // Fresh state (empty projectOrder) so syncProjects derives order from - // persistedProjectOrderCwds rather than the in-memory projectOrder branch. - const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); - - expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); - }); - - it("persists the default advertised endpoint preference", () => { - const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); - - persistState(state); - - const persisted = JSON.parse( - localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", - ) as PersistedUiState; - expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); - }); + it("drops the temporary expanded-only migration fallback when rewriting state", () => { + const migrated = parsePersistedState({ + expandedProjectCwds: ["/repo/a"], + }); - it("preserves expand state across restart when project's logical key changes", () => { - // After restart, in-memory previousExpandedById is empty, so the - // previousLogicalKey-to-state bridge in syncProjects cannot help. The - // persisted-cwd fallback is the only mechanism that can carry collapse - // state across a restart that also flips a project into a new logical - // group (e.g. late-arriving repo metadata). This locks in that path. - const physicalKey = "env-local:/lk-restart-proj"; - const previousLogicalKey = physicalKey; - const cwd = "/lk-restart-proj"; - - let state = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: previousLogicalKey, cwd }, - ]); - state = setProjectExpanded(state, previousLogicalKey, false); - persistState(state); + persistState(migrated); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; - hydratePersistedProjectState(persisted); - - const nextLogicalKey = "lk-restart-canonical"; - const rehydrated = syncProjects(makeUiState(), [ - { key: physicalKey, logicalKey: nextLogicalKey, cwd }, - ]); - - expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); + expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index f16495bed7f..4a97f0542b4 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,6 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ @@ -16,6 +17,9 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; export interface PersistedUiState { + projectExpandedById?: Record; + projectOrder?: string[]; + threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -39,19 +43,6 @@ export interface UiEndpointState { export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} -export interface SyncProjectInput { - /** Physical project key (env + cwd). Used for manual sort order. */ - key: string; - /** Logical group key. Used for expand/collapse state. */ - logicalKey: string; - cwd: string; -} - -export interface SyncThreadInput { - key: string; - seedVisitedAt?: string | undefined; -} - const initialState: UiState = { projectExpandedById: {}, projectOrder: [], @@ -60,20 +51,90 @@ const initialState: UiState = { defaultAdvertisedEndpointKey: null, }; -const persistedCollapsedProjectCwds = new Set(); -const persistedExpandedProjectCwds = new Set(); -const persistedProjectOrderCwds: string[] = []; -const persistedProjectOrderCwdSet = new Set(); -// Pre-fix persisted shape only listed expanded cwds, so anything not listed -// was treated as collapsed. Track whether the loaded blob carried the new -// `collapsedProjectCwds` field so we can preserve that legacy semantic for -// one session after upgrade, until persistState rewrites in the new shape. -let persistedProjectStateUsesLegacyShape = false; -const currentProjectCwdById = new Map(); -const currentProjectCwdsByLogicalKey = new Map(); -const currentLogicalKeyByPhysicalKey = new Map(); +const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; +const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; let legacyKeysCleanedUp = false; +export function legacyProjectCwdPreferenceKey(cwd: string): string { + return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`; +} + +function sanitizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return [ + ...new Set( + value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0), + ), + ]; +} + +function sanitizeBooleanRecord(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean", + ), + ); +} + +function sanitizeTimestampRecord(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => + entry[0].length > 0 && + typeof entry[1] === "string" && + entry[1].length > 0 && + Number.isFinite(Date.parse(entry[1])), + ), + ); +} + +export function parsePersistedState(parsed: PersistedUiState): UiState { + const projectExpandedById = + parsed.projectExpandedById === undefined + ? (() => { + const migrated: Record = {}; + const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds); + const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds); + for (const cwd of collapsedProjectCwds) { + migrated[legacyProjectCwdPreferenceKey(cwd)] = false; + } + for (const cwd of expandedProjectCwds) { + migrated[legacyProjectCwdPreferenceKey(cwd)] = true; + } + if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) { + migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false; + } + return migrated; + })() + : sanitizeBooleanRecord(parsed.projectExpandedById); + const projectOrder = + parsed.projectOrder === undefined + ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey) + : sanitizeStringArray(parsed.projectOrder); + + return { + projectExpandedById, + projectOrder, + threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), + threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( + parsed.threadChangedFilesExpandedById, + ), + defaultAdvertisedEndpointKey: + typeof parsed.defaultAdvertisedEndpointKey === "string" && + parsed.defaultAdvertisedEndpointKey.length > 0 + ? parsed.defaultAdvertisedEndpointKey + : null, + }; +} + function readPersistedState(): UiState { if (typeof window === "undefined") { return initialState; @@ -86,24 +147,11 @@ function readPersistedState(): UiState { if (!legacyRaw) { continue; } - hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState); - return initialState; + return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState); } return initialState; } - const parsed = JSON.parse(raw) as PersistedUiState; - hydratePersistedProjectState(parsed); - return { - ...initialState, - defaultAdvertisedEndpointKey: - typeof parsed.defaultAdvertisedEndpointKey === "string" && - parsed.defaultAdvertisedEndpointKey.length > 0 - ? parsed.defaultAdvertisedEndpointKey - : null, - threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( - parsed.threadChangedFilesExpandedById, - ), - }; + return parsePersistedState(JSON.parse(raw) as PersistedUiState); } catch { return initialState; } @@ -137,48 +185,16 @@ function sanitizePersistedThreadChangedFilesExpanded( return nextState; } -export function hydratePersistedProjectState(parsed: PersistedUiState): void { - persistedCollapsedProjectCwds.clear(); - persistedExpandedProjectCwds.clear(); - persistedProjectOrderCwds.length = 0; - persistedProjectOrderCwdSet.clear(); - persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); - for (const cwd of parsed.collapsedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedCollapsedProjectCwds.add(cwd); - } - } - for (const cwd of parsed.expandedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { - persistedExpandedProjectCwds.add(cwd); - } - } - for (const cwd of parsed.projectOrderCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) { - persistedProjectOrderCwdSet.add(cwd); - persistedProjectOrderCwds.push(cwd); - } - } -} - export function persistState(state: UiState): void { if (typeof window === "undefined") { return; } try { - // Persist collapsed cwds explicitly so an empty/missing field unambiguously - // means "first install" rather than "user collapsed everything"; without - // this, the syncProjects fallback would re-expand all rows on next launch. - const collapsedProjectCwds = Object.entries(state.projectExpandedById) - .filter(([, expanded]) => !expanded) - .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); - const expandedProjectCwds = Object.entries(state.projectExpandedById) - .filter(([, expanded]) => expanded) - .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); - const projectOrderCwds = state.projectOrder.flatMap((projectId) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); + const projectExpandedById = Object.fromEntries( + Object.entries(state.projectExpandedById).filter( + ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, + ), + ); const threadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { const nextTurns = Object.fromEntries( @@ -190,9 +206,9 @@ export function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ - collapsedProjectCwds, - expandedProjectCwds, - projectOrderCwds, + projectExpandedById, + projectOrder: state.projectOrder, + threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -210,242 +226,11 @@ export function persistState(state: UiState): void { const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); -function recordsEqual(left: Record, right: Record): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - for (const [key, value] of leftEntries) { - if (right[key] !== value) { - return false; - } - } - return true; -} - -function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { - return ( - left.length === right.length && left.every((projectId, index) => projectId === right[index]) - ); -} - -function nestedBooleanRecordsEqual( - left: Record>, - right: Record>, -): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - for (const [key, value] of leftEntries) { - if (!(key in right) || !recordsEqual(value, right[key]!)) { - return false; - } - } - return true; -} - -export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { - const previousProjectCwdById = new Map(currentProjectCwdById); - const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey); - currentProjectCwdById.clear(); - currentLogicalKeyByPhysicalKey.clear(); - for (const project of projects) { - currentProjectCwdById.set(project.key, project.cwd); - currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); - } - currentProjectCwdsByLogicalKey.clear(); - const currentProjectCwdSetsByLogicalKey = new Map>(); - for (const project of projects) { - const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); - if (cwds) { - let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey); - if (!cwdSet) { - cwdSet = new Set(cwds); - currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet); - } - if (!cwdSet.has(project.cwd)) { - cwdSet.add(project.cwd); - cwds.push(project.cwd); - } - } else { - currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); - currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd])); - } - } - // Build reverse map: for each new logical key, which previous logical keys - // did its member projects live under? Lets us preserve expand state when a - // project's logical key changes (e.g. late-arriving repo metadata flips the - // group identity). - const previousLogicalKeysByNewLogicalKey = new Map>(); - for (const project of projects) { - const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key); - if (!previousLogicalKey || previousLogicalKey === project.logicalKey) { - continue; - } - const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); - if (set) { - set.add(previousLogicalKey); - } else { - previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey])); - } - } - const cwdMappingChanged = - previousProjectCwdById.size !== currentProjectCwdById.size || - projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); - - const nextExpandedById: Record = {}; - const previousExpandedById = state.projectExpandedById; - const persistedOrderByCwd = new Map( - persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), - ); - const mappedProjects = projects.map((project, index) => { - if (!(project.logicalKey in nextExpandedById)) { - const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; - const fallbackFromPreviousLogicalKey = (() => { - const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); - if (!previousKeys) { - return undefined; - } - for (const previousKey of previousKeys) { - if (previousKey in previousExpandedById) { - return previousExpandedById[previousKey]; - } - } - return undefined; - })(); - const fallbackFromPersistedShape = (() => { - if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) { - return true; - } - if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { - return false; - } - if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { - return false; - } - return true; - })(); - const expanded = - previousExpandedById[project.logicalKey] ?? - fallbackFromPreviousLogicalKey ?? - fallbackFromPersistedShape; - nextExpandedById[project.logicalKey] = expanded; - } - return { - id: project.key, - cwd: project.cwd, - incomingIndex: index, - }; - }); - - const nextProjectOrder = - state.projectOrder.length > 0 - ? (() => { - const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); - const nextProjectIdByCwd = new Map( - mappedProjects.map((project) => [project.cwd, project.id] as const), - ); - const usedProjectIds = new Set(); - const orderedProjectIds: string[] = []; - - for (const projectId of state.projectOrder) { - const matchedProjectId = - (currentProjectIds.has(projectId) ? projectId : undefined) ?? - (() => { - const previousCwd = previousProjectCwdById.get(projectId); - return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; - })(); - if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) { - continue; - } - usedProjectIds.add(matchedProjectId); - orderedProjectIds.push(matchedProjectId); - } - - for (const project of mappedProjects) { - if (usedProjectIds.has(project.id)) { - continue; - } - orderedProjectIds.push(project.id); - } - - return orderedProjectIds; - })() - : mappedProjects - .map((project) => ({ - id: project.id, - incomingIndex: project.incomingIndex, - orderIndex: - persistedOrderByCwd.get(project.cwd) ?? - persistedProjectOrderCwds.length + project.incomingIndex, - })) - .toSorted((left, right) => { - const byOrder = left.orderIndex - right.orderIndex; - if (byOrder !== 0) { - return byOrder; - } - return left.incomingIndex - right.incomingIndex; - }) - .map((project) => project.id); - - if ( - recordsEqual(state.projectExpandedById, nextExpandedById) && - projectOrdersEqual(state.projectOrder, nextProjectOrder) && - !cwdMappingChanged - ) { +export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { + const visitedAtMs = Date.parse(visitedAt); + if (!Number.isFinite(visitedAtMs)) { return state; } - - return { - ...state, - projectExpandedById: nextExpandedById, - projectOrder: nextProjectOrder, - }; -} - -export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { - const retainedThreadIds = new Set(threads.map((thread) => thread.key)); - const nextThreadLastVisitedAtById = Object.fromEntries( - Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => - retainedThreadIds.has(threadId), - ), - ); - for (const thread of threads) { - if ( - nextThreadLastVisitedAtById[thread.key] === undefined && - thread.seedVisitedAt !== undefined && - thread.seedVisitedAt.length > 0 - ) { - nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; - } - } - const nextThreadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => - retainedThreadIds.has(threadId), - ), - ); - if ( - recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && - nestedBooleanRecordsEqual( - state.threadChangedFilesExpandedById, - nextThreadChangedFilesExpandedById, - ) - ) { - return state; - } - return { - ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, - }; -} - -export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { - const at = visitedAt ?? new Date().toISOString(); - const visitedAtMs = Date.parse(at); const previousVisitedAt = state.threadLastVisitedAtById[threadId]; const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; if ( @@ -459,7 +244,7 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt?: ...state, threadLastVisitedAtById: { ...state.threadLastVisitedAtById, - [threadId]: at, + [threadId]: visitedAt, }, }; } @@ -489,23 +274,6 @@ export function markThreadUnread( }; } -export function clearThreadUi(state: UiState, threadId: string): UiState { - const hasVisitedState = threadId in state.threadLastVisitedAtById; - const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; - if (!hasVisitedState && !hasChangedFilesState) { - return state; - } - const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; - const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; - delete nextThreadLastVisitedAtById[threadId]; - delete nextThreadChangedFilesExpandedById[threadId]; - return { - ...state, - threadLastVisitedAtById: nextThreadLastVisitedAtById, - threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, - }; -} - export function setThreadChangedFilesExpanded( state: UiState, threadId: string, @@ -566,32 +334,42 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } -export function toggleProject(state: UiState, projectId: string): UiState { - const expanded = state.projectExpandedById[projectId] ?? true; - return { - ...state, - projectExpandedById: { - ...state.projectExpandedById, - [projectId]: !expanded, - }, - }; +export function resolveProjectExpanded( + projectExpandedById: Readonly>, + preferenceKeys: readonly string[], +): boolean { + for (const key of preferenceKeys) { + const expanded = projectExpandedById[key]; + if (expanded !== undefined) { + return expanded; + } + } + return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true; } -export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { - if ((state.projectExpandedById[projectId] ?? true) === expanded) { +export function setProjectExpanded( + state: UiState, + projectIds: string | readonly string[], + expanded: boolean, +): UiState { + const ids = typeof projectIds === "string" ? [projectIds] : projectIds; + const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded); + if (nextEntries.length === 0) { return state; } + const projectExpandedById = { ...state.projectExpandedById }; + for (const projectId of nextEntries) { + projectExpandedById[projectId] = expanded; + } return { ...state, - projectExpandedById: { - ...state.projectExpandedById, - [projectId]: expanded, - }, + projectExpandedById, }; } export function reorderProjects( state: UiState, + currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ): UiState { @@ -604,12 +382,12 @@ export function reorderProjects( return state; } - const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id)); + const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id)); if (originalTargetIndex < 0) { return state; } - const projectOrder = [...state.projectOrder]; + const projectOrder = [...currentProjectOrder]; const removed: string[] = []; let draggedBeforeTarget = 0; @@ -634,16 +412,13 @@ export function reorderProjects( } interface UiStateStore extends UiState { - syncProjects: (projects: readonly SyncProjectInput[]) => void; - syncThreads: (threads: readonly SyncThreadInput[]) => void; - markThreadVisited: (threadId: string, visitedAt?: string) => void; + markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; - clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; - toggleProject: (projectId: string) => void; - setProjectExpanded: (projectId: string, expanded: boolean) => void; + setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( + currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; @@ -651,22 +426,20 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), - syncProjects: (projects) => set((state) => syncProjects(state, projects)), - syncThreads: (threads) => set((state) => syncThreads(state, threads)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), - clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), - toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - setProjectExpanded: (projectId, expanded) => - set((state) => setProjectExpanded(state, projectId, expanded)), - reorderProjects: (draggedProjectIds, targetProjectIds) => - set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), + setProjectExpanded: (projectIds, expanded) => + set((state) => setProjectExpanded(state, projectIds, expanded)), + reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => + set((state) => + reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), + ), })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 0354a966996..13ff2f0f73e 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,7 +10,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -21,12 +20,13 @@ function makeThread(overrides: Partial = {}): Thread { interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], - error: null, createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + deletedAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89afa4..109f71ccd9a 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,4 +1,4 @@ -import type { Thread } from "./types"; +import type { ThreadShell } from "./types"; function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); @@ -9,8 +9,8 @@ function normalizeWorktreePath(path: string | null): string | null { } export function getOrphanedWorktreePathForThread( - threads: readonly Thread[], - threadId: Thread["id"], + threads: ReadonlyArray>, + threadId: ThreadShell["id"], ): string | null { const targetThread = threads.find((thread) => thread.id === threadId); if (!targetThread) { diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts deleted file mode 100644 index a888f7fa5da..00000000000 --- a/apps/web/test/authHttpHandlers.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - AuthSessionId, - EnvironmentAuthenticatedAuth, - EnvironmentAuthenticatedPrincipal, - EnvironmentAuthHttpApi, - EnvironmentId, - EnvironmentMetadataHttpApi, - type AuthEnvironmentScope, - type ExecutionEnvironmentDescriptor, - type ServerAuthDescriptor, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import * as HttpApi from "effect/unstable/httpapi/HttpApi"; -import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { http } from "msw"; - -const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment") - .add(EnvironmentMetadataHttpApi) - .add(EnvironmentAuthHttpApi); - -const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"); -const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin", - arch: "arm64", - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const unexpectedEndpoint = (endpoint: string) => - Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`)); - -export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { - const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => - httpEffect.pipe( - Effect.provideService(EnvironmentAuthenticatedPrincipal, { - sessionId: AuthSessionId.make("browser-session"), - subject: "browser-client", - method: "browser-session-cookie", - scopes: new Set(), - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ), - ); - const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) => - handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)), - ); - const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) => - handlers - .handle("session", () => - Effect.succeed({ - authenticated: true, - auth: getAuthDescriptor(), - sessionMethod: "browser-session-cookie", - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ) - .handle("browserSession", () => - Effect.succeed({ - authenticated: true, - scopes: [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ], - sessionMethod: "browser-session-cookie", - expiresAt: TEST_SESSION_EXPIRES_AT, - }), - ) - .handle("token", () => unexpectedEndpoint("auth.token")) - .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) - .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential")) - .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) - .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) - .handle("clients", () => unexpectedEndpoint("auth.clients")) - .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient")) - .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")), - ).pipe(Layer.provide(authenticatedAuthLayer)); - const { handler } = HttpRouter.toWebHandler( - HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe( - Layer.provide(metadataLayer), - Layer.provide(authLayer), - Layer.provide(authenticatedAuthLayer), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true }, - ); - - return [ - http.all("*/.well-known/t3/environment", ({ request }) => handler(request)), - http.all("*/api/auth/*", ({ request }) => handler(request)), - ] as const; -} diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts deleted file mode 100644 index 94b8b53f3e1..00000000000 --- a/apps/web/test/wsRpcHarness.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as PubSub from "effect/PubSub"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; - -type RpcServerInstance = RpcServer.RpcServer; - -type BrowserWsClient = { - send: (data: string) => void; -}; - -export type NormalizedWsRpcRequestBody = { - _tag: string; - [key: string]: unknown; -}; - -type UnaryResolverResult = unknown | Promise; - -interface BrowserWsRpcHarnessOptions { - readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult; - readonly getInitialStreamValues?: ( - request: NormalizedWsRpcRequestBody, - ) => ReadonlyArray | undefined; -} - -const STREAM_METHODS = new Set([ - ORCHESTRATION_WS_METHODS.subscribeShell, - ORCHESTRATION_WS_METHODS.subscribeThread, - WS_METHODS.gitRunStackedAction, - WS_METHODS.terminalAttach, - WS_METHODS.subscribeVcsStatus, - WS_METHODS.subscribeTerminalEvents, - WS_METHODS.subscribeTerminalMetadata, - WS_METHODS.subscribeServerConfig, - WS_METHODS.subscribeServerLifecycle, - WS_METHODS.subscribeAuthAccess, -]); - -const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); - -function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody { - if (payload && typeof payload === "object" && !Array.isArray(payload)) { - return { - _tag: tag, - ...(payload as Record), - }; - } - return { _tag: tag, payload }; -} - -function asEffect(result: UnaryResolverResult): Effect.Effect { - if (result instanceof Promise) { - return Effect.promise(() => result); - } - return Effect.succeed(result); -} - -export class BrowserWsRpcHarness { - readonly requests: Array = []; - - private readonly parser = RpcSerialization.json.makeUnsafe(); - private client: BrowserWsClient | null = null; - private scope: Scope.Closeable | null = null; - private serverReady: Promise | null = null; - private resolveUnary: NonNullable = () => ({}); - private getInitialStreamValues: NonNullable< - BrowserWsRpcHarnessOptions["getInitialStreamValues"] - > = () => []; - private streamPubSubs = new Map>(); - - async reset(options?: BrowserWsRpcHarnessOptions): Promise { - await this.disconnect(); - this.requests.length = 0; - this.resolveUnary = options?.resolveUnary ?? (() => ({})); - this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []); - this.initializeStreamPubSubs(); - } - - connect(client: BrowserWsClient): void { - if (this.scope) { - void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); - } - if (this.streamPubSubs.size === 0) { - this.initializeStreamPubSubs(); - } - this.client = client; - this.scope = Effect.runSync(Scope.make()); - this.serverReady = Effect.runPromise( - Scope.provide(this.scope)( - RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()), - ).pipe(Effect.provide(this.makeLayer())), - ) as Promise; - } - - async disconnect(): Promise { - if (this.scope) { - await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined); - this.scope = null; - } - for (const pubsub of this.streamPubSubs.values()) { - Effect.runSync(PubSub.shutdown(pubsub)); - } - this.streamPubSubs.clear(); - this.serverReady = null; - this.client = null; - } - - private initializeStreamPubSubs(): void { - this.streamPubSubs = new Map( - Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded())]), - ); - } - - async onMessage(rawData: string): Promise { - const server = await this.serverReady; - if (!server) { - return; - } - const messages = this.parser.decode(rawData); - for (const message of messages) { - if (message && typeof message === "object" && "_tag" in message && message._tag === "Ping") { - const encoded = this.parser.encode(RpcMessage.constPong); - if (typeof encoded === "string") { - this.client?.send(encoded); - } - continue; - } - await Effect.runPromise(server.write(0, message as never)); - } - } - - emitStreamValue(method: string, value: unknown): void { - const pubsub = this.streamPubSubs.get(method); - if (!pubsub) { - throw new Error(`No stream registered for ${method}`); - } - Effect.runSync(PubSub.publish(pubsub, value)); - } - - private makeLayer() { - const handlers: Record unknown> = {}; - for (const method of ALL_RPC_METHODS) { - handlers[method] = STREAM_METHODS.has(method) - ? (payload) => this.handleStream(method, payload) - : (payload) => this.handleUnary(method, payload); - } - return WsRpcGroup.toLayer(handlers as never); - } - - private makeServerOptions() { - return { - onFromServer: (response: unknown) => - Effect.sync(() => { - if (!this.client) { - return; - } - const encoded = this.parser.encode(response); - if (typeof encoded === "string") { - this.client.send(encoded); - } - }), - }; - } - - private handleUnary(method: string, payload: unknown) { - const request = normalizeRequest(method, payload); - this.requests.push(request); - return asEffect(this.resolveUnary(request)); - } - - private handleStream(method: string, payload: unknown) { - const request = normalizeRequest(method, payload); - this.requests.push(request); - const pubsub = this.streamPubSubs.get(method); - if (!pubsub) { - throw new Error(`No stream registered for ${method}`); - } - return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe( - Stream.concat(Stream.fromPubSub(pubsub)), - ); - } -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bfa0f8e4610..43c79eba305 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,7 +2,6 @@ import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; -import { playwright } from "vite-plus/test/browser-playwright"; import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; @@ -59,30 +58,6 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; -const browserTestProject = { - extends: true, - server: { - // Browser tests need concurrent runs to claim the next available port. - strictPort: false, - }, - test: { - name: "browser", - include: ["src/components/**/*.browser.tsx"], - hookTimeout: 30_000, - testTimeout: 30_000, - browser: { - enabled: true, - provider: playwright() as never, - instances: [{ browser: "chromium" }], - headless: true, - api: { - strictPort: false, - }, - }, - fileParallelism: false, - }, -} satisfies TestProjectInlineConfiguration; - function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { if (!wsUrl) { return undefined; @@ -123,6 +98,8 @@ export default defineConfig(() => { ], optimizeDeps: { include: [ + "@clerk/clerk-js", + "@clerk/react/internal", "@pierre/diffs", "@pierre/diffs/editor", "@pierre/diffs/react", @@ -186,7 +163,7 @@ export default defineConfig(() => { sourcemap: buildSourcemap, }, test: { - projects: [defineProject(unitTestProject), defineProject(browserTestProject)], + projects: [defineProject(unitTestProject)], }, }; }); diff --git a/docs/architecture/connection-runtime.md b/docs/architecture/connection-runtime.md new file mode 100644 index 00000000000..06f7e6338ca --- /dev/null +++ b/docs/architecture/connection-runtime.md @@ -0,0 +1,137 @@ +# Connection Runtime + +The connection runtime is shared by web and mobile. It owns connectivity, +authentication, retries, transport lifetime, cached environment data, and +environment-scoped operations. + +Web and mobile mount this runtime once at the application root. There is no +legacy connection owner or supported mixed mode. + +## Ownership + +Each registered environment has one scoped Effect `Context` containing focused +services: + +- `EnvironmentSupervisor` owns desired state, retry scheduling, and the active + session scope. +- `ConnectionBroker` prepares credentials and endpoints for primary, bearer, + relay, and SSH targets. +- `RpcSessionFactory` performs one transport attempt. It does not retry. +- `EnvironmentRpc` exposes the active session without leaking the transport. +- `EnvironmentProjectCommands` and `EnvironmentThreadCommands` construct + orchestration commands, IDs, and timestamps. +- `EnvironmentShell` and `EnvironmentThreads` own live subscriptions and cached + snapshots. + +`EnvironmentServicesFactory` assembles that context, and `EnvironmentRegistry` +owns its scope. There is no aggregate environment runtime facade. React +components do not create connections, transports, retry loops, or RPC clients. + +## Connection State + +The supervisor is the only retry owner. + +1. A persisted or platform registration marks an environment as desired. +2. If the device is offline, the supervisor releases the active session and + waits without consuming retry attempts. +3. When online, the supervisor asks the broker for one prepared connection and + asks the session factory for one RPC session. +4. Transient failures retry forever with exponential backoff capped at 16 + seconds. +5. Connectivity changes, application activation, credential changes, and + explicit user retry interrupt the current wait and trigger a fresh attempt. +6. Authentication or configuration failures remain blocked until an external + wakeup changes the relevant input. +7. An involuntary session close keeps the registration and cache, then retries. +8. Explicit removal closes the session and deletes the registration, + credentials, shell cache, and thread cache. + +The UI derives `available`, `offline`, `connecting`, `reconnecting`, +`connected`, and `error` from supervisor state plus explicit data-sync state. +It does not infer connection health from cached data or the existence of a +transport object. An environment becomes `connected` after the socket opens and +the initial config RPC succeeds, proving that the server is responsive. Shell +and thread synchronization are independent data states. A healthy RPC +transport with a failed shell subscription is shown as connected with a +synchronization error, not as a reconnect that is not actually scheduled. + +## Data Boundary + +Finite requests, durable subscriptions, and commands are separate APIs: + +- Query atoms revalidate when the RPC generation changes. +- Subscription atoms switch to replacement sessions. +- Expected subscription failures update domain sync state and wait for a + replacement session; they do not take down a healthy transport. +- Mutations resolve the current environment runtime at execution time. +- Shell and thread snapshots are available while offline. +- A connected transport may have `empty`, `cached`, `synchronizing`, `live`, or + failed shell and thread data independently. +- Cached shell and thread projections are never allowed to overwrite newer live + data during a fast reconnect. +- Domain atom factories route effects through the environment registry and + resolve the current scoped service at execution time. +- Web and mobile own their Atom runtimes, React hooks, and feature composition. + +The Promise bridge exists only at the React/Atom boundary. Runtime and business +logic remain Effect-native. + +## Platform Layers + +Web and mobile provide: + +- network status and network-change streams; +- application lifecycle wakeups; +- cloud session credentials; +- device identity; +- platform registrations; +- persistent catalog, credential, shell, and thread stores; +- HTTP, crypto, and telemetry layers. + +Platform layers adapt operating-system capabilities. They do not implement +connection policy. + +## Source Boundaries + +The public package subpaths mirror the runtime layers: + +- `connection/core` contains state, catalog, retry policy, and connectivity. +- `connection/transport` contains brokerage, authorization, attempts, and RPC + sessions. +- `connection/platform` declares capabilities and persistence contracts. +- `connection/services` contains environment-scoped data services. +- `connection/application` assembles registries, discovery, and startup. +- `connection/atoms` adapts shared services to application-owned Atom runtimes. +- `connection/presentation` contains pure UI projections. + +Other reusable state lives in domain subpaths such as `shell`, `threads`, +`terminal`, and `vcs`. Applications must import explicit package subpaths; the +package intentionally has no root export. + +## Application Boundary + +The application root mounts the shared connection application layer, creates +its own Atom runtime, and selects the domain atom factories required by that +platform. Web and mobile may expose different hooks and features without +changing connection ownership. + +Application code must not construct `WsTransport`, RPC clients, retry loops, or +raw orchestration commands. Persistence paths belong to the platform +registration and cache stores, with explicit migration or invalidation policy. + +## Verification + +Core state-machine tests use `@effect/vitest` and deterministic service layers. +Required coverage includes: + +- offline startup and online wakeup; +- forever retry with the 16-second cap; +- explicit retry interrupting backoff; +- authentication wakeups; +- involuntary close and reconnect; +- explicit removal clearing all owned state; +- relay token reuse and refresh; +- progressive relay discovery; +- shell and thread cache hydration; +- durable subscriptions switching sessions; +- command metadata and idempotent queued-command metadata. diff --git a/infra/relay/README.md b/infra/relay/README.md index 697fa30cac0..114d5e9b07f 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -45,7 +45,7 @@ credential, or authorization behavior. Shared request and response schemas live in [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay calls live in -[`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). +[`packages/client-runtime/src/relay/managedRelay.ts`](../../packages/client-runtime/src/relay/managedRelay.ts). ## Working Locally diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index 171981834c8..d4ca885b86c 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -88,20 +88,22 @@ describe("RelayTokens", () => { proofKeyThumbprint: "proof-key-thumbprint", jti: "access-token-1", issuedAtEpochSeconds: 100, - expiresAtEpochSeconds: 200, + expiresAtEpochSeconds: 1_900, clientId: "t3-mobile", scopes: ["environment:connect", "environment:status", "mobile:registration"], }); expect( - yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 700 }), ).toMatchObject({ sub: "user_123", cnf: { jkt: "proof-key-thumbprint" }, client_id: "t3-mobile", scope: ["environment:connect", "environment:status", "mobile:registration"], }); - expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 261 })).toBeNull(); + expect( + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 1_961 }), + ).toBeNull(); }).pipe(Effect.provide(layer)), ); diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index f7f02c49f8c..db0a9499e0a 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -26,6 +26,7 @@ import * as RelayConfiguration from "../Config.ts"; const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; const LINK_CHALLENGE_KIND = "environment_link_challenge"; +export const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; const LinkChallengeClaims = Schema.Struct({ kind: Schema.Literal(LINK_CHALLENGE_KIND), @@ -71,6 +72,17 @@ const allowedScopesByClientId: Record< [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), }; +function relayJwtVerificationFailureReason(error: RelayJwtError): string { + const cause = error.cause; + if (typeof cause === "object" && cause !== null && "code" in cause) { + const code = (cause as { readonly code?: unknown }).code; + if (typeof code === "string" && code.length > 0) { + return code; + } + } + return cause instanceof Error && cause.name ? cause.name : "unknown"; +} + function resolveDpopAccessTokenScopes(input: { readonly clientId: RelayPublicClientId; readonly scope: string; @@ -195,7 +207,14 @@ const make = Effect.gen(function* () { issuer, audience: issuer, nowEpochSeconds: input.nowEpochSeconds, + maxTokenAge: RELAY_DPOP_ACCESS_TOKEN_TTL, }).pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan( + "relay.tokens.verification_failure", + relayJwtVerificationFailureReason(error), + ), + ), Effect.flatMap(decodeDpopAccessTokenClaims), Effect.map((claims): RelayDpopAccessTokenClaims | null => { const scopes = resolveDpopAccessTokenScopes({ diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index c6bec6d4bae..c3b86e7ba4c 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -535,6 +535,7 @@ describe("EnvironmentConnector", () => { environmentId: "env-connector-test", status: "offline", error: "Managed endpoint health request failed: Environment is unavailable.", + traceId: expect.any(String), }); }).pipe(Effect.provide(connectorTestLayer(execute))); }); diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index c62d1166962..784fb535344 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -5,7 +5,7 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { RelayCloudEnvironmentHealthProofPayload, RelayEnvironmentHealthResponse, @@ -35,7 +35,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; @@ -179,6 +179,24 @@ function environmentHealthRequestFailureMessage(cause: unknown): string { : "Managed endpoint health request failed."; } +function environmentHealthRequestFailureReason(cause: unknown): string { + if (isEnvironmentHealthError(cause)) { + return cause._tag; + } + if (HttpClientError.isHttpClientError(cause)) { + return cause.reason._tag; + } + if (Schema.isSchemaError(cause)) { + return "SchemaError"; + } + return cause instanceof Error && cause.name ? cause.name : "Unknown"; +} + +const currentTraceId = Effect.currentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => "unavailable"), +); + const withoutRedirects = (effect: Effect.Effect) => effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); @@ -457,6 +475,7 @@ const make = Effect.gen(function* () { ), ); const checkedAt = DateTime.formatIso(now); + const traceId = yield* currentTraceId; const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe( withoutRedirects, @@ -467,21 +486,44 @@ const make = Effect.gen(function* () { Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), ); if (Option.isNone(responseOption)) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_health.outcome": "timeout", + "relay.environment_health.trace_id": traceId, + }); + yield* Effect.logWarning("Managed endpoint health request timed out", { + environmentId: link.environmentId, + endpoint: endpoint.httpBaseUrl, + traceId, + }); return { environmentId: link.environmentId, endpoint, status: "offline" as const, checkedAt, error: "Managed endpoint health request timed out.", + traceId, }; } if (responseOption.value._tag === "Failure") { + const failureReason = environmentHealthRequestFailureReason(responseOption.value.cause); + yield* Effect.annotateCurrentSpan({ + "relay.environment_health.outcome": "failure", + "relay.environment_health.failure_reason": failureReason, + "relay.environment_health.trace_id": traceId, + }); + yield* Effect.logWarning("Managed endpoint health request failed", { + environmentId: link.environmentId, + endpoint: endpoint.httpBaseUrl, + failureReason, + traceId, + }); return { environmentId: link.environmentId, endpoint, status: "offline" as const, checkedAt, error: environmentHealthRequestFailureMessage(responseOption.value.cause), + traceId, }; } const decoded = responseOption.value.response; diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fa2a2fec686..cc34e315aca 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -69,7 +69,6 @@ import { withSpanAttributes } from "../observability.ts"; import { RelayDb } from "../db.ts"; const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; -const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; const relayCorsAllowedHeaders = [ "authorization", "b3", @@ -599,7 +598,7 @@ export const tokenApi = HttpApiBuilder.group( Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), ); const now = yield* DateTime.now; - const expiresAt = DateTime.addDuration(now, RELAY_DPOP_ACCESS_TOKEN_TTL); + const expiresAt = DateTime.addDuration(now, RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL); const jti = yield* crypto.randomUUIDv4.pipe( Effect.catch(() => relayInternalErrorResponse("internal_error")), ); @@ -617,7 +616,7 @@ export const tokenApi = HttpApiBuilder.group( .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), issued_token_type: RelayAccessTokenType, token_type: "DPoP" as const, - expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL), + expires_in: Duration.toSeconds(RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL), scope: encodeOAuthScope(requestedScopes), }; }, mapRelayCommonApiErrors("invalid_dpop")), diff --git a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts index f34ded78a56..7cb316001a7 100644 --- a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts +++ b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts @@ -13,22 +13,26 @@ const COMPILER_METHODS = new Set([ "decodeExit", "decodeOption", "decodePromise", + "decodeResult", "decodeSync", "decodeUnknownExit", "decodeUnknownEffect", "decodeUnknownOption", "decodeUnknownPromise", + "decodeUnknownResult", "decodeUnknownSync", "encodeExit", "encodeEffect", "encodeOption", "encodePromise", + "encodeResult", "encodeSync", "encodeUnknownExit", "encodeUnknownEffect", "encodeUnknownOption", "encodeUnknownPromise", + "encodeUnknownResult", "encodeUnknownSync", ]); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index fb0ca1c65f5..7494476e27e 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -48,7 +48,7 @@ const LEGACY_BASELINE = new Map([ ["apps/web/src/cloud/dpop.test.ts", 2], ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], - ["packages/client-runtime/src/managedRelayState.test.ts", 1], + ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], ["packages/client-runtime/src/wsTransport.test.ts", 2], ]); diff --git a/packages/client-runtime/README.md b/packages/client-runtime/README.md new file mode 100644 index 00000000000..722d6f6d389 --- /dev/null +++ b/packages/client-runtime/README.md @@ -0,0 +1,31 @@ +# Client Runtime + +Shared client behavior for web and mobile. Public APIs are organized by package +subpath. The package intentionally has no root export. + +## Public subpaths + +| Subpath | Responsibility | +| --------------------- | ---------------------------------------------------------------- | +| `authorization` | Bearer and DPoP authorization plus token persistence contracts | +| `connection` | Targets, catalog, supervision, retries, registry, and onboarding | +| `environment` | Environment identity, descriptors, endpoints, and scoped keys | +| `errors` | Shared client error inspection | +| `operations` | Multi-step application workflows | +| `operations/projects` | Multi-step project creation workflows | +| `platform` | Platform capability and persistence service contracts | +| `relay` | Managed relay API and environment discovery | +| `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | +| `state/` | Focused shared state, retention, reducers, and Atom constructors | + +## Dependency direction + +Platform applications provide `platform` services. `connection` composes those +capabilities with `authorization`, `relay`, and `rpc` to supervise environment +sessions. Independent `state` modules consume the connection registry and expose +focused state or Atom constructors to application-owned runtimes. + +Applications should import the narrowest relevant subpath. There is no broad +`state` export: use domain paths such as `state/shell`, `state/threads`, +`state/terminal`, or `state/vcs`. Subpath indices and explicitly exported domain +files are public API boundaries; all other files remain implementation details. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bf1c1bdc0c0..87fba65e2e1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -2,15 +2,126 @@ "name": "@t3tools/client-runtime", "private": true, "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", "exports": { - ".": { - "types": "./src/index.ts", - "react-native": "./src/index.ts", - "import": "./src/index.ts", - "require": "./src/index.ts", - "default": "./src/index.ts" + "./connection": { + "types": "./src/connection/index.ts", + "default": "./src/connection/index.ts" + }, + "./authorization": { + "types": "./src/authorization/index.ts", + "default": "./src/authorization/index.ts" + }, + "./environment": { + "types": "./src/environment/index.ts", + "default": "./src/environment/index.ts" + }, + "./errors": { + "types": "./src/errors/index.ts", + "default": "./src/errors/index.ts" + }, + "./rpc": { + "types": "./src/rpc/index.ts", + "default": "./src/rpc/index.ts" + }, + "./operations": { + "types": "./src/operations/index.ts", + "default": "./src/operations/index.ts" + }, + "./operations/projects": { + "types": "./src/operations/projects.ts", + "default": "./src/operations/projects.ts" + }, + "./platform": { + "types": "./src/platform/index.ts", + "default": "./src/platform/index.ts" + }, + "./relay": { + "types": "./src/relay/index.ts", + "default": "./src/relay/index.ts" + }, + "./state/auth": { + "types": "./src/state/auth.ts", + "default": "./src/state/auth.ts" + }, + "./state/assets": { + "types": "./src/state/assets.ts", + "default": "./src/state/assets.ts" + }, + "./state/connections": { + "types": "./src/state/connections.ts", + "default": "./src/state/connections.ts" + }, + "./state/entities": { + "types": "./src/state/entities.ts", + "default": "./src/state/entities.ts" + }, + "./state/filesystem": { + "types": "./src/state/filesystem.ts", + "default": "./src/state/filesystem.ts" + }, + "./state/git": { + "types": "./src/state/git.ts", + "default": "./src/state/git.ts" + }, + "./state/models": { + "types": "./src/state/models.ts", + "default": "./src/state/models.ts" + }, + "./state/orchestration": { + "types": "./src/state/orchestration.ts", + "default": "./src/state/orchestration.ts" + }, + "./state/presentation": { + "types": "./src/state/presentation.ts", + "default": "./src/state/presentation.ts" + }, + "./state/preview": { + "types": "./src/state/preview.ts", + "default": "./src/state/preview.ts" + }, + "./state/projects": { + "types": "./src/state/projects.ts", + "default": "./src/state/projects.ts" + }, + "./state/relay": { + "types": "./src/state/relayDiscovery.ts", + "default": "./src/state/relayDiscovery.ts" + }, + "./state/review": { + "types": "./src/state/review.ts", + "default": "./src/state/review.ts" + }, + "./state/runtime": { + "types": "./src/state/runtime.ts", + "default": "./src/state/runtime.ts" + }, + "./state/server": { + "types": "./src/state/server.ts", + "default": "./src/state/server.ts" + }, + "./state/session": { + "types": "./src/state/session.ts", + "default": "./src/state/session.ts" + }, + "./state/shell": { + "types": "./src/state/shell.ts", + "default": "./src/state/shell.ts" + }, + "./state/source-control": { + "types": "./src/state/sourceControl.ts", + "default": "./src/state/sourceControl.ts" + }, + "./state/terminal": { + "types": "./src/state/terminal.ts", + "default": "./src/state/terminal.ts" + }, + "./state/threads": { + "types": "./src/state/threads.ts", + "default": "./src/state/threads.ts" + }, + "./state/vcs": { + "types": "./src/state/vcs.ts", + "default": "./src/state/vcs.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/advertisedEndpoint.ts b/packages/client-runtime/src/advertisedEndpoint.ts deleted file mode 100644 index da7d766fa80..00000000000 --- a/packages/client-runtime/src/advertisedEndpoint.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@t3tools/shared/advertisedEndpoint"; diff --git a/packages/client-runtime/src/archivedThreadsState.test.ts b/packages/client-runtime/src/archivedThreadsState.test.ts deleted file mode 100644 index 3a819fa30b9..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type ArchivedThreadsClient, - createArchivedThreadsManager, - makeArchivedThreadsEnvironmentKey, - parseArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "./archivedThreadsState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createSnapshot(id: string): OrchestrationShellSnapshot { - return { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: `2026-05-08T00:00:00.000Z`, - id, - } as OrchestrationShellSnapshot; -} - -describe("createArchivedThreadsManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads archived snapshots for configured environment clients", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const clients = new Map([ - [ - envA, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")), - }, - ], - [ - envB, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")), - }, - ], - ]); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => clients.get(environmentId) ?? null, - }); - - const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA]))); - - await vi.waitFor(() => { - const state = readArchivedThreadsSnapshotState( - registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))), - ); - expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]); - }); - expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true); - }); - - it("refreshes known snapshot groups that include an environment", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const getArchivedShellSnapshot = vi.fn(async () => - createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`), - ); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null), - staleTimeMs: 60_000, - }); - - const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB])); - registry.get(atom); - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1)); - - manager.refreshForEnvironment(envA); - - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2)); - }); - - it("round-trips environment keys in sorted order", () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const key = makeArchivedThreadsEnvironmentKey([envB, envA]); - - expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); - }); -}); diff --git a/packages/client-runtime/src/archivedThreadsState.ts b/packages/client-runtime/src/archivedThreadsState.ts deleted file mode 100644 index b1d6ec59e4e..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type ArchivedSnapshotEntry = { - readonly environmentId: EnvironmentId; - readonly snapshot: OrchestrationShellSnapshot; -}; - -export interface ArchivedThreadsClient { - readonly getArchivedShellSnapshot: () => Promise; -} - -export interface ArchivedThreadsSnapshotState { - readonly snapshots: ReadonlyArray; - readonly error: string | null; - readonly isLoading: boolean; -} - -const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; -const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000; -const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; -const environmentIdOrder = Order.String as Order.Order; - -export function makeArchivedThreadsEnvironmentKey( - environmentIds: ReadonlyArray, -): string { - return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => - sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - ); -} - -export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { - if (key.length === 0) { - return []; - } - return pipe( - key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - Arr.map((environmentId) => EnvironmentId.make(environmentId)), - ); -} - -export function readArchivedThreadsSnapshotState( - result: AsyncResult.AsyncResult, unknown>, -): ArchivedThreadsSnapshotState { - const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); - let error: string | null = null; - if (result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Failed to load archived threads."; - } - - return { - snapshots, - error, - isLoading: result.waiting, - }; -} - -export function createArchivedThreadsManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -}) { - const knownEnvironmentKeys = new Set(); - const knownEnvironmentIdsByKey = new Map>(); - const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS; - const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS; - - const snapshotsAtom = Atom.family((environmentKey: string) => { - knownEnvironmentKeys.add(environmentKey); - knownEnvironmentIdsByKey.set( - environmentKey, - new Set(parseArchivedThreadsEnvironmentKey(environmentKey)), - ); - return Atom.make( - Effect.promise(async (): Promise> => { - const snapshots = await Promise.all( - pipe( - parseArchivedThreadsEnvironmentKey(environmentKey), - Arr.map(async (environmentId) => { - const client = config.getClient(environmentId); - if (!client) { - return null; - } - return { - environmentId, - snapshot: await client.getArchivedShellSnapshot(), - }; - }), - ), - ); - return pipe( - snapshots, - Arr.filterMap((snapshot) => - snapshot !== null ? Result.succeed(snapshot) : Result.failVoid, - ), - ); - }), - ).pipe( - Atom.swr({ - staleTime, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), - ); - }); - - function getAtom(environmentKey: string) { - return snapshotsAtom(environmentKey); - } - - function refresh(environmentIds: ReadonlyArray): void { - config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds))); - } - - function refreshForEnvironment(environmentId: EnvironmentId): void { - for (const environmentKey of knownEnvironmentKeys) { - if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) { - config.getRegistry().refresh(getAtom(environmentKey)); - } - } - } - - return { - getAtom, - refresh, - refreshForEnvironment, - }; -} diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts new file mode 100644 index 00000000000..06137d1fd5c --- /dev/null +++ b/packages/client-runtime/src/authorization/index.ts @@ -0,0 +1,4 @@ +export * from "./layer.ts"; +export * from "./remote.ts"; +export * from "./service.ts"; +export * from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts new file mode 100644 index 00000000000..b65eacaa794 --- /dev/null +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -0,0 +1,344 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { ClientPresentation } from "../platform/capabilities.ts"; +import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; +import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const DESCRIPTOR = { + environmentId: ENVIRONMENT_ID, + label: "Remote environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; +const BOOTSTRAP: RelayEnvironmentAuthorization = { + environmentId: ENVIRONMENT_ID, + endpoint: ENDPOINT, + credential: "relay-bootstrap", +}; + +function recordedFetch(responses: ReadonlyArray) { + const calls: Array = []; + let responseIndex = 0; + const fetchFn = ((input, init) => { + calls.push([input, init ?? {}]); + const response = responses[responseIndex++]; + return response === undefined + ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) + : Promise.resolve(response); + }) satisfies typeof fetch; + return { calls, fetchFn }; +} + +const websocketTicket = (ticket: string) => + Response.json({ + ticket, + expiresAt: "2026-06-06T01:00:00.000Z", + }); + +const accessToken = (token: string) => + Response.json({ + access_token: token, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3_600, + scope: AuthStandardClientScopes.join(" "), + }); + +const authInvalid = () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-invalid", + }, + { status: 401 }, + ); + +const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { + readonly initialToken?: RemoteDpopAccessToken; + readonly responses: ReadonlyArray; +}) { + const tokens = yield* Ref.make( + new Map( + input.initialToken === undefined + ? [] + : [[input.initialToken.environmentId, input.initialToken]], + ), + ); + const bootstrapCalls = yield* Ref.make(0); + const proofInputs = yield* Ref.make< + ReadonlyArray<{ + readonly method: string; + readonly url: string; + readonly accessToken?: string; + }> + >([]); + const fetch = recordedFetch(input.responses); + + const tokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(tokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const signer = ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("thumbprint-1"), + createProof: (proofInput) => + Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( + Effect.as(`proof:${proofInput.url}`), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + ), + }); + const layer = remoteEnvironmentAuthorizationLayer.pipe( + Layer.provide( + Layer.mergeAll( + remoteHttpClientLayer(fetch.fetchFn), + Layer.succeed(ManagedRelayDpopSigner, signer), + Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed( + ClientPresentation, + ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "mobile", + os: "test", + }, + scopes: AuthStandardClientScopes, + }), + ), + ), + ), + ); + const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe( + Effect.as(BOOTSTRAP), + ); + + return { + layer, + tokens, + bootstrapCalls, + proofInputs, + fetch, + obtainBootstrap, + }; +}); + +describe("RemoteEnvironmentAuthorization", () => { + it.effect("reuses a valid persisted environment token without contacting the relay", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [websocketTicket("cached-ticket")], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect(harness.fetch.calls).toHaveLength(1); + expect(String(harness.fetch.calls[0]?.[0])).toBe( + "https://environment.example.test/api/auth/websocket-ticket", + ); + }), + ); + + it.effect("refreshes and persists an expired environment token", () => + Effect.gen(function* () { + const expired = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "expired-access-token", + expiresAtEpochMs: 0, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: expired, + responses: [ + Response.json(DESCRIPTOR), + accessToken("fresh-access-token"), + websocketTicket("fresh-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=fresh-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "fresh-access-token", + dpopThumbprint: "thumbprint-1", + }), + ); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); + + it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "invalid-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + authInvalid(), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(4); + }), + ); + + it.effect("refreshes a cached endpoint after consecutive transient failures", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + new Response("endpoint unavailable", { status: 503 }), + new Response("endpoint still unavailable", { status: 503 }), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + const firstFailure = yield* remote + .authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }) + .pipe(Effect.flip); + + expect(firstFailure._tag).toBe("ConnectionTransientError"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached); + + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(5); + }), + ); + + it.effect("does not persist a refreshed token until its websocket ticket succeeds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + responses: [ + Response.json(DESCRIPTOR), + accessToken("unusable-access-token"), + new Response("endpoint unavailable", { status: 503 }), + ], + }); + + yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer), Effect.flip); + + expect((yield* Ref.get(harness.tokens)).has(ENVIRONMENT_ID)).toBe(false); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); +}); diff --git a/packages/client-runtime/src/authorization/layer.ts b/packages/client-runtime/src/authorization/layer.ts new file mode 100644 index 00000000000..9b71edf0461 --- /dev/null +++ b/packages/client-runtime/src/authorization/layer.ts @@ -0,0 +1,268 @@ +import { + exchangeRemoteDpopAccessToken, + type RemoteEnvironmentAuthError, + resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl, +} from "./remote.ts"; +import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ClientPresentation } from "../platform/capabilities.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { RemoteEnvironmentAuthorization } from "./service.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; + +const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; +const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; + +function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { + return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" + ? error + : mapRemoteEnvironmentError(error); +} + +const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( + httpBaseUrl: string, +) { + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); +}); + +export const remoteEnvironmentAuthorizationLayer = Layer.effect( + RemoteEnvironmentAuthorization, + Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + const presentation = yield* ClientPresentation; + const tokenStore = yield* RemoteDpopAccessTokenStore; + const httpClient = yield* HttpClient.HttpClient; + const cachedEndpointFailures = yield* Ref.make>(new Map()); + + const resetCachedEndpointFailures = (environmentId: string) => + Ref.update(cachedEndpointFailures, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + + const recordCachedEndpointFailure = (environmentId: string) => + Ref.modify(cachedEndpointFailures, (current) => { + const failureCount = (current.get(environmentId) ?? 0) + 1; + const next = new Map(current); + next.set(environmentId, failureCount); + return [failureCount, next] as const; + }); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) { + const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }; + }, + ); + + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( + function* (token: RemoteDpopAccessToken) { + const ticketProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not create the websocket authorization proof.", + }), + ), + ); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: token.endpoint.wsBaseUrl, + httpBaseUrl: token.endpoint.httpBaseUrl, + accessToken: token.accessToken, + dpopProof: ticketProof, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + ); + + const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["expectedEnvironmentId"]; + readonly obtainBootstrap: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["obtainBootstrap"]; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not load the environment authorization key.", + }), + ), + Effect.withSpan("environment.authorization.dpopKey.resolve"), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore + .get(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); + if ( + Option.isSome(cached) && + cached.value.environmentId === input.expectedEnvironmentId && + cached.value.dpopThumbprint === thumbprint && + cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "hit", + }); + const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); + if (Result.isSuccess(cachedSocket)) { + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + return { + environmentId: cached.value.environmentId, + label: cached.value.label, + httpBaseUrl: cached.value.endpoint.httpBaseUrl, + socketUrl: cachedSocket.success, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + } + if (cachedSocket.failure._tag === "ConnectionBlockedError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } + const mappedFailure = mapDpopSocketError(cachedSocket.failure); + if (mappedFailure._tag === "ConnectionTransientError") { + const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); + if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { + return yield* mappedFailure; + } + } + yield* tokenStore + .remove(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + } + + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "miss", + }); + const bootstrap = yield* input.obtainBootstrap; + const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.descriptor"), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not create the environment authorization proof.", + }), + ), + ); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + credential: bootstrap.credential, + dpopProof: bootstrapProof, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.accessToken.exchange"), + ); + const issuedAt = yield* Clock.currentTimeMillis; + const token = new RemoteDpopAccessToken({ + environmentId: descriptor.environmentId, + label: descriptor.label, + endpoint: bootstrap.endpoint, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + const socketUrl = yield* createDpopSocketUrl(token).pipe( + Effect.mapError(mapDpopSocketError), + ); + yield* tokenStore + .put(token) + .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: token.accessToken, + }, + }; + }, + ); + + return RemoteEnvironmentAuthorization.of({ + authorizeBearer, + authorizeDpop: (input) => + authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + }); + }), +); diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts similarity index 97% rename from packages/client-runtime/src/remote.test.ts rename to packages/client-runtime/src/authorization/remote.test.ts index c20832bd37e..6e6ccc86052 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -10,15 +10,15 @@ import { bootstrapRemoteBearerSession, exchangeRemoteDpopAccessToken, fetchRemoteDpopSessionState, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteDpopWebSocketTicket, issueRemoteWebSocketTicket, - remoteHttpClientLayer, RemoteEnvironmentAuthInvalidJsonError, RemoteEnvironmentAuthTimeoutError, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); @@ -88,7 +88,7 @@ const expectFetchCall = ( } }; -describe("remote", () => { +describe("remote environment authorization", () => { it.effect("bootstraps bearer auth against a remote backend", () => Effect.gen(function* () { const fetch = recordedFetch( @@ -391,7 +391,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); expect(error.message).toBe( - "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", + "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", ); }).pipe(Effect.provide(TestClock.layer())), ); @@ -446,7 +446,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); expect(error.message).toBe( - "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.", + "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.", ); }), ); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts new file mode 100644 index 00000000000..69c157d0e50 --- /dev/null +++ b/packages/client-runtime/src/authorization/remote.ts @@ -0,0 +1,214 @@ +import { + AuthAccessTokenType, + type AuthClientPresentationMetadata, + AuthEnvironmentBootstrapTokenType, + AuthTokenExchangeGrantType, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; +import * as Effect from "effect/Effect"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; + +export { + RemoteEnvironmentAuthFetchError, + RemoteEnvironmentAuthInvalidJsonError, + RemoteEnvironmentAuthTimeoutError, + RemoteEnvironmentAuthUndeclaredStatusError, +} from "../rpc/http.ts"; +export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +const clientMetadataTokenExchangeFields = ( + clientMetadata: AuthClientPresentationMetadata | undefined, +) => ({ + ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), + ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), + ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), +}); + +export const exchangeRemoteDpopAccessToken = Effect.fn( + "clientRuntime.authorization.exchangeRemoteDpopAccessToken", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const response = yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: { dpop: input.dpopProof }, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); + return response; +}); + +export const bootstrapRemoteBearerSession = Effect.fn( + "clientRuntime.authorization.bootstrapRemoteBearerSession", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: {}, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); +}); + +export const fetchRemoteSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const fetchRemoteDpopSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteDpopSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const issueRemoteWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const issueRemoteDpopWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteDpopWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const resolveRemoteWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); + +export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteDpopWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteDpopWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + accessToken: input.accessToken, + dpopProof: input.dpopProof, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts new file mode 100644 index 00000000000..2a39edfd074 --- /dev/null +++ b/packages/client-runtime/src/authorization/service.ts @@ -0,0 +1,39 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; + +export interface RelayEnvironmentAuthorization { + readonly environmentId: EnvironmentId; + readonly endpoint: RelayManagedEndpoint; + readonly credential: string; +} + +export interface AuthorizedRemoteEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization; +} + +export class RemoteEnvironmentAuthorization extends Context.Service< + RemoteEnvironmentAuthorization, + { + readonly authorizeBearer: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly authorizeDpop: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly obtainBootstrap: Effect.Effect< + RelayEnvironmentAuthorization, + ConnectionAttemptError + >; + }) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} diff --git a/packages/client-runtime/src/authorization/tokenStore.ts b/packages/client-runtime/src/authorization/tokenStore.ts new file mode 100644 index 00000000000..e00cc4cfdff --- /dev/null +++ b/packages/client-runtime/src/authorization/tokenStore.ts @@ -0,0 +1,30 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export class RemoteDpopAccessToken extends Schema.Class( + "@t3tools/client-runtime/authorization/RemoteDpopAccessToken", +)({ + environmentId: EnvironmentId, + label: Schema.String, + endpoint: RelayManagedEndpoint, + accessToken: Schema.String, + expiresAtEpochMs: Schema.Number, + dpopThumbprint: Schema.String, +}) {} + +export class RemoteDpopAccessTokenStore extends Context.Service< + RemoteDpopAccessTokenStore, + { + readonly get: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (token: RemoteDpopAccessToken) => Effect.Effect; + readonly remove: (environmentId: EnvironmentId) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} diff --git a/packages/client-runtime/src/checkpointDiffState.test.ts b/packages/client-runtime/src/checkpointDiffState.test.ts deleted file mode 100644 index c5fa51e3d36..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type CheckpointDiffClient, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "./checkpointDiffState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, -}; - -const PATCH_RESULT: OrchestrationGetTurnDiffResult = { - threadId: TARGET.threadId, - diff: "patch", - fromTurnCount: 1, - toTurnCount: 2, -}; - -function createClient() { - return { - getTurnDiff: vi.fn(async () => PATCH_RESULT), - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - } satisfies CheckpointDiffClient; -} - -describe("createCheckpointDiffManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads a turn checkpoint diff into atom state", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getFullThreadDiff).not.toHaveBeenCalled(); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: PATCH_RESULT, - error: null, - isPending: false, - }); - }); - - it("loads a full thread diff when the range starts at zero", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await manager.load({ ...TARGET, fromTurnCount: 0 }); - - expect(client.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getTurnDiff).not.toHaveBeenCalled(); - }); - - it("returns empty state for invalid targets", () => { - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => createClient(), - }); - - expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE); - expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull(); - }); - - it("deduplicates in-flight requests and reuses successful cached data", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - const first = manager.load(TARGET); - const second = manager.load(TARGET); - - expect(first).toBe(second); - await first; - await manager.load(TARGET); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(1); - }); - - it("retries temporarily unavailable checkpoint diffs", async () => { - let attempts = 0; - const client = { - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - getTurnDiff: vi.fn(async () => { - attempts += 1; - if (attempts < 3) { - throw new Error("checkpoint is unavailable for turn"); - } - return PATCH_RESULT; - }), - } satisfies CheckpointDiffClient; - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - retryDelay: async () => undefined, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/client-runtime/src/checkpointDiffState.ts b/packages/client-runtime/src/checkpointDiffState.ts deleted file mode 100644 index b0752584bc6..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - type EnvironmentId, - OrchestrationGetFullThreadDiffInput, - type OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - type OrchestrationGetTurnDiffResult, - type ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type CheckpointDiffResult = - | OrchestrationGetTurnDiffResult - | OrchestrationGetFullThreadDiffResult; - -export interface CheckpointDiffState { - readonly data: CheckpointDiffResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface CheckpointDiffTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly fromTurnCount: number | null; - readonly toTurnCount: number | null; - readonly ignoreWhitespace: boolean; - readonly cacheScope?: string | null; -} - -export interface CheckpointDiffClient { - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Promise; - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Promise; -} - -export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownCheckpointDiffKeys = new Set(); - -export const checkpointDiffStateAtom = Atom.family((key: string) => { - knownCheckpointDiffKeys.add(key); - return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`checkpoint-diff:${key}`), - ); -}); - -export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("checkpoint-diff:null"), -); - -const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); -const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); - -type CheckpointDiffRequest = - | { - readonly kind: "fullThreadDiff"; - readonly input: OrchestrationGetFullThreadDiffInput; - } - | { - readonly kind: "turnDiff"; - readonly input: OrchestrationGetTurnDiffInput; - }; - -export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null { - const decoded = decodeCheckpointDiffRequest(target); - if (target.environmentId === null || decoded._tag === "None") { - return null; - } - - return [ - target.environmentId, - target.threadId, - target.fromTurnCount, - target.toTurnCount, - target.ignoreWhitespace, - target.cacheScope ?? null, - ].join(":"); -} - -function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) { - if (target.fromTurnCount === 0) { - return decodeFullThreadDiffInput({ - threadId: target.threadId, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input }))); - } - - return decodeTurnDiffInput({ - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input }))); -} - -function asCheckpointErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return ""; -} - -export function normalizeCheckpointDiffErrorMessage(error: unknown): string { - const message = asCheckpointErrorMessage(error).trim(); - if (message.length === 0) { - return "Failed to load checkpoint diff."; - } - - const lower = message.toLowerCase(); - if (lower.includes("not a git repository")) { - return "Turn diffs are unavailable because this project is not a git repository."; - } - - if ( - lower.includes("checkpoint unavailable for thread") || - lower.includes("checkpoint invariant violation") - ) { - const separatorIndex = message.indexOf(":"); - if (separatorIndex >= 0) { - const detail = message.slice(separatorIndex + 1).trim(); - if (detail.length > 0) { - return detail; - } - } - } - - return message; -} - -function isCheckpointTemporarilyUnavailable(error: unknown): boolean { - const message = asCheckpointErrorMessage(error).toLowerCase(); - return ( - message.includes("exceeds current turn count") || - message.includes("checkpoint is unavailable for turn") || - message.includes("filesystem checkpoint is unavailable") - ); -} - -function defaultRetryDelay(attempt: number, error: unknown): Promise { - const delayMs = isCheckpointTemporarilyUnavailable(error) - ? Math.min(5_000, 250 * 2 ** (attempt - 1)) - : Math.min(1_000, 100 * 2 ** (attempt - 1)); - return Effect.runPromise(Effect.sleep(Duration.millis(delayMs))); -} - -export function createCheckpointDiffManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null; - readonly retryDelay?: (attempt: number, error: unknown) => Promise; -}) { - const inFlight = new Map>(); - const versions = new Map(); - - function getVersion(targetKey: string): number { - return versions.get(targetKey) ?? 0; - } - - function bumpVersion(targetKey: string): void { - versions.set(targetKey, getVersion(targetKey) + 1); - } - - function setState(targetKey: string, state: CheckpointDiffState): void { - config.getRegistry().set(checkpointDiffStateAtom(targetKey), state); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true }, - ); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: normalizeCheckpointDiffErrorMessage(error), - isPending: false, - }); - } - - async function requestWithRetry( - client: CheckpointDiffClient, - request: CheckpointDiffRequest, - ): Promise { - let attempt = 0; - while (true) { - attempt += 1; - try { - if (request.kind === "fullThreadDiff") { - return await client.getFullThreadDiff(request.input); - } - return await client.getTurnDiff(request.input); - } catch (error) { - const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4; - if (attempt >= maxAttempts) { - throw error; - } - await (config.retryDelay ?? defaultRetryDelay)(attempt, error); - } - } - } - - function load( - target: CheckpointDiffTarget, - client?: CheckpointDiffClient, - options?: { readonly force?: boolean }, - ): Promise { - const targetKey = getCheckpointDiffTargetKey(target); - const decoded = decodeCheckpointDiffRequest(target); - if (targetKey === null || target.environmentId === null || decoded._tag === "None") { - return Promise.resolve(null); - } - - if (!options?.force) { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - if (current.data !== null && current.error === null) { - return Promise.resolve(current.data); - } - } - - const existing = inFlight.get(targetKey); - if (existing) { - return existing; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setError(targetKey, new Error("Remote connection is not ready.")); - return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data); - } - - markPending(targetKey); - const version = getVersion(targetKey); - const promise = requestWithRetry(resolved, decoded.value).then( - (result) => { - if (getVersion(targetKey) === version) { - setState(targetKey, { data: result, error: null, isPending: false }); - } - return result; - }, - (error: unknown) => { - if (getVersion(targetKey) === version) { - setError(targetKey, error); - } - return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data; - }, - ); - inFlight.set(targetKey, promise); - void promise.finally(() => { - if (inFlight.get(targetKey) === promise) { - inFlight.delete(targetKey); - } - }); - return promise; - } - - function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState { - const targetKey = getCheckpointDiffTargetKey(target); - return targetKey === null - ? EMPTY_CHECKPOINT_DIFF_STATE - : config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - } - - function invalidate(target?: CheckpointDiffTarget): void { - if (target) { - const targetKey = getCheckpointDiffTargetKey(target); - if (targetKey === null) { - return; - } - bumpVersion(targetKey); - inFlight.delete(targetKey); - setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE); - return; - } - - for (const key of knownCheckpointDiffKeys) { - bumpVersion(key); - setState(key, INITIAL_CHECKPOINT_DIFF_STATE); - } - inFlight.clear(); - } - - return { - getSnapshot, - invalidate, - load, - }; -} diff --git a/packages/client-runtime/src/composerPathSearchState.test.ts b/packages/client-runtime/src/composerPathSearchState.test.ts deleted file mode 100644 index 8e5c739ba5d..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - type ComposerPathSearchClient, - createComposerPathSearchManager, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - getComposerPathSearchTargetKey, -} from "./composerPathSearchState.ts"; - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -const TARGET = { - environmentId: "env-local" as EnvironmentId, - cwd: "/repo", - query: "src", -}; - -it("derives null keys for inactive path searches", () => { - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null); -}); - -it("stores path search results in atom state", async () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: async () => ({ - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - truncated: false, - }), - }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - isPending: false, - error: null, - }); -}); - -it("reuses fresh cached path search results", async () => { - const searchEntries = vi.fn(async () => ({ - entries: [{ path: "src/index.ts", kind: "file" as const }], - truncated: false, - })); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - manager.search(TARGET); - await flushAsyncWork(); - - assert.strictEqual(searchEntries.mock.calls.length, 1); - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/index.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("invalidates watched path searches and refreshes without clearing entries", async () => { - type SearchResult = Awaited>; - - let resolveSecond: (value: SearchResult) => void = noop; - let callCount = 0; - const searchEntries = vi.fn((() => { - callCount += 1; - if (callCount === 1) { - return Promise.resolve({ - entries: [{ path: "src/old.ts", kind: "file" as const }], - truncated: false, - }); - } - return new Promise((resolve) => { - resolveSecond = resolve; - }); - }) satisfies ComposerPathSearchClient["searchEntries"]); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - const unwatch = manager.watch(TARGET); - await flushAsyncWork(); - manager.invalidate(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/old.ts", kind: "file" }], - isPending: true, - error: null, - }); - - resolveSecond({ - entries: [{ path: "src/new.ts", kind: "file" }], - truncated: false, - }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/new.ts", kind: "file" }], - isPending: false, - error: null, - }); - assert.strictEqual(searchEntries.mock.calls.length, 2); - unwatch(); -}); - -it("ignores stale path search results after a newer request starts", async () => { - let resolveFirst: (value: { - entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; - truncated: boolean; - }) => void = noop; - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: (input: Parameters[0]) => { - if (input.query === "first") { - return new Promise((resolve) => { - resolveFirst = resolve; - }); - } - return Promise.resolve({ - entries: [{ path: "second.ts", kind: "file" }], - truncated: false, - }); - }, - }), - }); - - manager.search({ ...TARGET, query: "first" }); - manager.search({ ...TARGET, query: "second" }); - await flushAsyncWork(); - resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), { - entries: [{ path: "second.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("returns the empty snapshot for inactive targets", () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - getClient: () => null, - }); - - assert.deepStrictEqual( - manager.getSnapshot({ environmentId: null, cwd: null, query: null }), - EMPTY_COMPOSER_PATH_SEARCH_STATE, - ); -}); diff --git a/packages/client-runtime/src/composerPathSearchState.ts b/packages/client-runtime/src/composerPathSearchState.ts deleted file mode 100644 index 693d60cb46f..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.ts +++ /dev/null @@ -1,341 +0,0 @@ -import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface ComposerPathSearchEntry { - readonly path: string; - readonly kind: "file" | "directory"; -} - -export interface ComposerPathSearchState { - readonly entries: ReadonlyArray; - readonly isPending: boolean; - readonly error: string | null; -} - -export interface ComposerPathSearchTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query: string | null; -} - -export interface ComposerPathSearchClient { - readonly searchEntries: (input: { - readonly cwd: string; - readonly query: string; - readonly limit: number; - }) => Promise; -} - -interface WatchedEntry { - refCount: number; - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }; - teardown: () => void; -} - -export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: false, - error: null, -}); - -const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: true, - error: null, -}); - -const NOOP: () => void = () => undefined; -const DEFAULT_DEBOUNCE_MS = 200; -const DEFAULT_LIMIT = 20; - -export const composerPathSearchStateAtom = Atom.family((key: string) => - Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`composer-path-search:${key}`), - ), -); - -export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("composer-path-search:null"), -); - -export function normalizeComposerPathSearchQuery(query: string | null): string { - return query?.trim() ?? ""; -} - -export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null { - const query = normalizeComposerPathSearchQuery(target.query); - if (target.environmentId === null || target.cwd === null || query.length === 0) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${query}`; -} - -function toSearchEntries( - entries: ProjectSearchEntriesResult["entries"], -): ReadonlyArray { - return entries; -} - -export function createComposerPathSearchManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly debounceMs?: number; - readonly limit?: number; - readonly staleTimeMs?: number; -}) { - const watched = new Map(); - const versions = new Map(); - const timers = new Map>(); - const lastLoadedAt = new Map(); - const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; - const limit = config.limit ?? DEFAULT_LIMIT; - - function bumpVersion(targetKey: string): number { - const next = (versions.get(targetKey) ?? 0) + 1; - versions.set(targetKey, next); - return next; - } - - function setState(targetKey: string, state: ComposerPathSearchState): void { - config.getRegistry().set(composerPathSearchStateAtom(targetKey), state); - } - - function clearTimer(targetKey: string): void { - const fiber = timers.get(targetKey); - if (fiber) { - Effect.runFork(Fiber.interrupt(fiber)); - timers.delete(targetKey); - } - } - - function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState { - const targetKey = getComposerPathSearchTargetKey(target); - return targetKey === null - ? EMPTY_COMPOSER_PATH_SEARCH_STATE - : config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - } - - function runSearch( - targetKey: string, - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }, - client: ComposerPathSearchClient, - version: number, - ): void { - void client - .searchEntries({ - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - limit, - }) - .then((result) => { - if (versions.get(targetKey) !== version) { - return; - } - setState(targetKey, { - entries: toSearchEntries(result.entries), - isPending: false, - error: null, - }); - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - }) - .catch((error: unknown) => { - if (versions.get(targetKey) !== version) { - return; - } - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState(targetKey, { - entries: current.entries, - isPending: false, - error: error instanceof Error ? error.message : "Failed to search project files.", - }); - }); - } - - function search( - target: ComposerPathSearchTarget, - client?: ComposerPathSearchClient, - options?: { readonly force?: boolean }, - ): void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - !options?.force && - lastLoaded !== undefined && - config.staleTimeMs !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const version = bumpVersion(targetKey); - clearTimer(targetKey); - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState( - targetKey, - current.entries.length === 0 - ? PENDING_COMPOSER_PATH_SEARCH_STATE - : { ...current, isPending: true, error: null }, - ); - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - if (debounceMs <= 0) { - runSearch(targetKey, readyTarget, resolved, version); - return; - } - - const fiber = Effect.runFork( - Effect.sleep(Duration.millis(debounceMs)).pipe( - Effect.andThen( - Effect.sync(() => { - timers.delete(targetKey); - runSearch(targetKey, readyTarget, resolved, version); - }), - ), - ), - ); - timers.set(targetKey, fiber); - } - - function watch(target: ComposerPathSearchTarget): () => void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let currentClient: ComposerPathSearchClient | null = null; - const sync = () => { - const client = config.getClient(target.environmentId!); - if (!client) { - currentClient = null; - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - if (currentClient === client) { - return; - } - - currentClient = client; - search(readyTarget, client); - }; - - const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP; - sync(); - - watched.set(targetKey, { - refCount: 1, - target: readyTarget, - teardown: () => { - unsubscribe(); - clearTimer(targetKey); - bumpVersion(targetKey); - }, - }); - - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - versions.clear(); - for (const targetKey of timers.keys()) { - clearTimer(targetKey); - } - lastLoadedAt.clear(); - } - - function invalidate(target?: ComposerPathSearchTarget): void { - if (target) { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null) { - return; - } - lastLoadedAt.delete(targetKey); - const watchedEntry = watched.get(targetKey); - if (watchedEntry) { - search(watchedEntry.target, undefined, { force: true }); - } - return; - } - - lastLoadedAt.clear(); - for (const watchedEntry of watched.values()) { - search(watchedEntry.target, undefined, { force: true }); - } - } - - return { - invalidate, - getSnapshot, - search, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts new file mode 100644 index 00000000000..2a94ab70454 --- /dev/null +++ b/packages/client-runtime/src/connection/catalog.ts @@ -0,0 +1,143 @@ +import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionAttemptError } from "./model.ts"; +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; + +const ConnectionProfileBase = { + connectionId: Schema.String, + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class BearerConnectionProfile extends Schema.TaggedClass()( + "BearerConnectionProfile", + { + ...ConnectionProfileBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class SshConnectionProfile extends Schema.TaggedClass()( + "SshConnectionProfile", + { + ...ConnectionProfileBase, + target: DesktopSshEnvironmentTargetSchema, + }, +) {} + +export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]); +export type ConnectionProfile = typeof ConnectionProfile.Type; + +export interface ConnectionCatalogEntry { + readonly target: ConnectionTarget; + readonly profile: Option.Option; +} + +export class BearerConnectionCredential extends Schema.TaggedClass()( + "BearerConnectionCredential", + { + token: Schema.String, + }, +) {} + +export const ConnectionCredential = Schema.Union([BearerConnectionCredential]); +export type ConnectionCredential = typeof ConnectionCredential.Type; + +export class PrimaryConnectionRegistration extends Schema.TaggedClass()( + "PrimaryConnectionRegistration", + { + target: PrimaryConnectionTarget, + }, +) {} + +export class RelayConnectionRegistration extends Schema.TaggedClass()( + "RelayConnectionRegistration", + { + target: RelayConnectionTarget, + }, +) {} + +export class BearerConnectionRegistration extends Schema.TaggedClass()( + "BearerConnectionRegistration", + { + target: BearerConnectionTarget, + profile: BearerConnectionProfile, + credential: BearerConnectionCredential, + }, +) {} + +export class SshConnectionRegistration extends Schema.TaggedClass()( + "SshConnectionRegistration", + { + target: SshConnectionTarget, + profile: SshConnectionProfile, + }, +) {} + +export const ConnectionRegistration = Schema.Union([ + RelayConnectionRegistration, + BearerConnectionRegistration, + SshConnectionRegistration, +]); +export type ConnectionRegistration = typeof ConnectionRegistration.Type; + +export function connectionRegistrationTarget( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionTarget { + return registration.target; +} + +export function connectionRegistrationCatalogEntry( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionCatalogEntry { + switch (registration._tag) { + case "PrimaryConnectionRegistration": + case "RelayConnectionRegistration": + return { + target: registration.target, + profile: Option.none(), + }; + case "BearerConnectionRegistration": + case "SshConnectionRegistration": + return { + target: registration.target, + profile: Option.some(registration.profile), + }; + } +} + +export class ConnectionProfileStore extends Context.Service< + ConnectionProfileStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (profile: ConnectionProfile) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} + +export class ConnectionCredentialStore extends Context.Service< + ConnectionCredentialStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: ( + connectionId: string, + credential: ConnectionCredential, + ) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts new file mode 100644 index 00000000000..44b38a3082e --- /dev/null +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { NetworkStatus } from "./model.ts"; + +export class Connectivity extends Context.Service< + Connectivity, + { + readonly status: Effect.Effect; + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/connectivity") {} diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts new file mode 100644 index 00000000000..c1a8f67a759 --- /dev/null +++ b/packages/client-runtime/src/connection/driver.ts @@ -0,0 +1,66 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { + ConnectionAttemptError, + ConnectionAttemptStage, + PreparedConnection, +} from "./model.ts"; +import { ConnectionResolver } from "./resolver.ts"; +import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; + +export type ConnectionDriverProgress = + | { + readonly stage: "preparing"; + } + | { + readonly stage: Exclude; + readonly prepared: PreparedConnection; + }; + +export interface EnvironmentConnectionLease { + readonly prepared: PreparedConnection; + readonly session: RpcSession; +} + +export interface ConnectionDriverService { + readonly connect: ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) => Effect.Effect; +} + +export class ConnectionDriver extends Context.Service()( + "@t3tools/client-runtime/connection/driver/ConnectionDriver", +) {} + +export const connectionDriverLayer = Layer.effect( + ConnectionDriver, + Effect.gen(function* () { + const resolver = yield* ConnectionResolver; + const sessions = yield* RpcSessionFactory; + + const connect = Effect.fn("ConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* resolver.prepare(entry); + yield* reportProgress({ stage: "opening", prepared }); + const session = yield* sessions.connect(prepared); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + return ConnectionDriver.of({ connect }); + }), +); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts new file mode 100644 index 00000000000..ab5baec3364 --- /dev/null +++ b/packages/client-runtime/src/connection/errors.ts @@ -0,0 +1,140 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayProtectedError } from "@t3tools/contracts/relay"; +import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; +import { + ConnectionBlockedError, + type ConnectionAttemptError, + ConnectionTransientError, +} from "./model.ts"; + +export function profileMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${connectionId} is unavailable.`, + }); +} + +export function credentialMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "authentication", + message: `Connection credential ${connectionId} is unavailable.`, + }); +} + +export function environmentMismatchError(input: { + readonly expected: EnvironmentId; + readonly actual: EnvironmentId; +}): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + message: `Connected environment ${input.actual} does not match ${input.expected}.`, + }); +} + +function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError { + switch (error._tag) { + case "RelayAuthInvalidError": + case "RelayEnvironmentLinkProofExpiredError": + case "RelayAgentActivityPublishProofExpiredError": + case "RelayAgentActivityPublishProofInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentConnectNotAuthorizedError": + case "RelayEnvironmentLinkProofInvalidError": + return new ConnectionBlockedError({ + reason: "permission", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointTimedOutError": + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointUnavailableError": + case "RelayEnvironmentLinkUnavailableError": + return new ConnectionTransientError({ + reason: "endpoint-unavailable", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentLinkFailedError": + case "RelayInternalError": + return new ConnectionTransientError({ + reason: "relay-unavailable", + message: error.message, + traceId: error.traceId, + }); + } +} + +export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { + if (error.relayError) { + return relayProtectedError(error.relayError); + } + if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); + } + return new ConnectionTransientError({ + reason: "relay-unavailable", + message: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); +} + +export function mapRemoteEnvironmentError( + error: RemoteEnvironmentAuthError, +): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + message: "The environment credential is invalid.", + traceId: error.traceId, + }); + case "EnvironmentScopeRequiredError": + case "EnvironmentOperationForbiddenError": + return new ConnectionBlockedError({ + reason: "permission", + message: "The environment credential does not grant the required access.", + traceId: error.traceId, + }); + case "EnvironmentRequestInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + message: "The environment rejected the authentication request.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + }); + case "RemoteEnvironmentAuthFetchError": + return new ConnectionTransientError({ + reason: "network", + message: error.message, + }); + case "EnvironmentInternalError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: "The environment could not authorize the connection.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthInvalidJsonError": + case "RemoteEnvironmentAuthUndeclaredStatusError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: error.message, + }); + } +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts new file mode 100644 index 00000000000..eb1db447bff --- /dev/null +++ b/packages/client-runtime/src/connection/index.ts @@ -0,0 +1,12 @@ +export * from "./catalog.ts"; +export * from "./connectivity.ts"; +export * from "./driver.ts"; +export * from "./errors.ts"; +export * from "./layer.ts"; +export * from "./model.ts"; +export * from "./onboarding.ts"; +export * from "./presentation.ts"; +export * from "./registry.ts"; +export * from "./resolver.ts"; +export * from "./supervisor.ts"; +export * from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts new file mode 100644 index 00000000000..c485c6c1b2c --- /dev/null +++ b/packages/client-runtime/src/connection/layer.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { connectionResolverLayer } from "./resolver.ts"; +import { connectionDriverLayer } from "./driver.ts"; +import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; +import { connectionOnboardingLayer } from "./onboarding.ts"; +import { PlatformConnectionSource } from "../platform/source.ts"; +import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; +import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; +import { rpcSessionFactoryLayer } from "../rpc/session.ts"; + +const resolverLayer = connectionResolverLayer.pipe( + Layer.provide(remoteEnvironmentAuthorizationLayer), +); + +const driverLayer = connectionDriverLayer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), +); + +const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); + +const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); + +const connectionServicesLayer = Layer.mergeAll( + registryLayer, + relayEnvironmentDiscoveryLayer, + onboardingLayer, +); + +const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.registerPlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), +); + +export const connectionLayer = connectionStartupLayer.pipe( + Layer.provideMerge(connectionServicesLayer), +); diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts new file mode 100644 index 00000000000..5c1daf090e4 --- /dev/null +++ b/packages/client-runtime/src/connection/model.ts @@ -0,0 +1,168 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const ConnectionTargetBase = { + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class PrimaryConnectionTarget extends Schema.TaggedClass()( + "PrimaryConnectionTarget", + { + ...ConnectionTargetBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class BearerConnectionTarget extends Schema.TaggedClass()( + "BearerConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export class RelayConnectionTarget extends Schema.TaggedClass()( + "RelayConnectionTarget", + { + ...ConnectionTargetBase, + }, +) {} + +export class SshConnectionTarget extends Schema.TaggedClass()( + "SshConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export const ConnectionTarget = Schema.Union([ + PrimaryConnectionTarget, + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type ConnectionTarget = typeof ConnectionTarget.Type; + +export const PersistedConnectionTarget = Schema.Union([ + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; + +export type ConnectionTargetKind = ConnectionTarget["_tag"]; + +export type NetworkStatus = "unknown" | "offline" | "online"; + +export type ConnectionTransientReason = + | "network" + | "timeout" + | "transport" + | "endpoint-unavailable" + | "relay-unavailable" + | "remote-unavailable"; + +export type ConnectionBlockedReason = + | "authentication" + | "configuration" + | "permission" + | "unsupported"; + +export class ConnectionTransientError extends Schema.TaggedErrorClass()( + "ConnectionTransientError", + { + reason: Schema.Literals([ + "network", + "timeout", + "transport", + "endpoint-unavailable", + "relay-unavailable", + "remote-unavailable", + ]), + message: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) {} + +export class ConnectionBlockedError extends Schema.TaggedErrorClass()( + "ConnectionBlockedError", + { + reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), + message: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) {} + +export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; + +export type PreparedHttpAuthorization = + | { + readonly _tag: "Bearer"; + readonly token: string; + } + | { + readonly _tag: "Dpop"; + readonly accessToken: string; + }; + +export interface PreparedConnection { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization | null; + readonly target: ConnectionTarget; +} + +export type SupervisorConnectionPhase = + | "available" + | "offline" + | "connecting" + | "backoff" + | "connected" + | "blocked"; + +export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing"; + +export interface SupervisorConnectionState { + readonly desired: boolean; + readonly network: NetworkStatus; + readonly phase: SupervisorConnectionPhase; + readonly stage: ConnectionAttemptStage | null; + readonly attempt: number; + readonly generation: number; + readonly lastFailure: ConnectionAttemptError | null; + readonly retryAt: number | null; +} + +export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready"; + +export function connectionProjectionPhase( + state: SupervisorConnectionState, +): ConnectionProjectionPhase { + switch (state.phase) { + case "connecting": + return "synchronizing"; + case "connected": + return "ready"; + case "available": + case "offline": + case "backoff": + case "blocked": + return "disconnected"; + } +} + +export const AVAILABLE_CONNECTION_STATE: SupervisorConnectionState = Object.freeze({ + desired: false, + network: "unknown", + phase: "available", + stage: null, + attempt: 0, + generation: 0, + lastFailure: null, + retryAt: null, +}); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts new file mode 100644 index 00000000000..9bee0dad6fb --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -0,0 +1,257 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; +import { BearerConnectionTarget } from "./model.ts"; +import { + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, +} from "./onboarding.ts"; + +const CLIENT_PRESENTATION_LAYER = Layer.succeed( + ClientPresentation, + ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "desktop", + os: "Test OS", + }, + scopes: AuthStandardClientScopes, + }), +); + +function pairingHttpLayer( + calls: Array<{ readonly url: string; readonly init: RequestInit }>, + options?: { readonly failDescriptor?: boolean }, +) { + const fetchFn = ((input, init = {}) => { + const url = String(input); + calls.push({ url, init }); + + if (url.endsWith("/.well-known/t3/environment")) { + if (options?.failDescriptor === true) { + return Promise.resolve( + Response.json({ message: "descriptor unavailable" }, { status: 503 }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "environment-paired", + label: "Paired environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + ); + } + + if (url.endsWith("/oauth/token")) { + return Promise.resolve( + Response.json({ + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: AuthStandardClientScopes.join(" "), + }), + ); + } + + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }) satisfies typeof fetch; + + return remoteHttpClientLayer(fetchFn); +} + +describe("connection onboarding", () => { + it.effect("prepares a persisted bearer registration from pairing details", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls)))); + + expect(registration).toMatchObject({ + _tag: "BearerConnectionRegistration", + target: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + httpBaseUrl: "https://remote.example.test/", + wsBaseUrl: "wss://remote.example.test/", + }, + credential: { + token: "bearer-token", + }, + }); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + "https://remote.example.test/oauth/token", + ]); + + const tokenRequest = calls.find((call) => call.url.endsWith("/oauth/token")); + const tokenBody = + tokenRequest?.init.body instanceof Uint8Array + ? new TextDecoder().decode(tokenRequest.init.body) + : String(tokenRequest?.init.body); + const tokenParams = new URLSearchParams(tokenBody); + expect(tokenParams.get("subject_token")).toBe("pairing-token"); + expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + expect(tokenParams.get("client_label")).toBe("T3 Code Test"); + }), + ); + + it.effect("does not consume a pairing credential when descriptor discovery fails", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + + yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { failDescriptor: true }), + ), + ), + Effect.flip, + ); + + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }), + ); + + it.effect("rejects invalid pairing details before making a request", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "", + pairingCode: "", + }).pipe( + Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + message: "Enter a backend URL.", + }); + expect(calls).toEqual([]); + }), + ); + + it.effect("updates bearer metadata while preserving the credential and identity", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-paired"); + const registration = yield* prepareBearerConnectionUpdate({ + input: { + environmentId, + label: " Renamed environment ", + httpBaseUrl: "http://100.65.180.100:3773/path", + }, + entry: Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "Old label", + connectionId: "bearer:environment-paired", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:environment-paired", + environmentId, + label: "Old label", + httpBaseUrl: "http://old.example.test/", + wsBaseUrl: "ws://old.example.test/", + }), + ), + }), + credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), + }); + + expect(registration).toMatchObject({ + target: { + environmentId, + label: "Renamed environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId, + label: "Renamed environment", + httpBaseUrl: "http://100.65.180.100:3773/", + wsBaseUrl: "ws://100.65.180.100:3773/", + }, + credential: { token: "bearer-token" }, + }); + }), + ); + + it.effect("prepares an SSH registration from the provisioned platform environment", () => + Effect.gen(function* () { + const target = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }; + const registration = yield* prepareSshRegistration({ + target, + }).pipe( + Effect.provideService( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.succeed({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "Remote development box", + bootstrap: { + target, + httpBaseUrl: "http://127.0.0.1:3201", + wsBaseUrl: "ws://127.0.0.1:3201", + pairingToken: "pairing-token", + }, + bearerToken: "bearer-token", + }), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.die("unused"), + }), + ), + ); + + expect(registration).toMatchObject({ + _tag: "SshConnectionRegistration", + target: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + }, + profile: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + target, + }, + }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts new file mode 100644 index 00000000000..14f71b5859b --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -0,0 +1,267 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient } from "effect/unstable/http"; + +import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; +import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionCatalogEntry, + type ConnectionCredential, + ConnectionCredentialStore, + SshConnectionProfile, + SshConnectionRegistration, +} from "./catalog.ts"; +import { mapRemoteEnvironmentError } from "./errors.ts"; +import { + BearerConnectionTarget, + ConnectionBlockedError, + SshConnectionTarget, + type ConnectionAttemptError, +} from "./model.ts"; +import type { ConnectionPersistenceError } from "../platform/persistence.ts"; +import { EnvironmentRegistry } from "./registry.ts"; + +export interface PairingConnectionInput { + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; +} + +export interface SshConnectionInput { + readonly target: DesktopSshEnvironmentTarget; + readonly label?: string; +} + +export interface BearerConnectionUpdateInput { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; +} + +export class ConnectionOnboarding extends Context.Service< + ConnectionOnboarding, + { + readonly registerPairing: ( + input: PairingConnectionInput, + ) => Effect.Effect; + readonly registerSsh: ( + input: SshConnectionInput, + ) => Effect.Effect; + readonly updateBearer: ( + input: BearerConnectionUpdateInput, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} + +const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( + function* (input: PairingConnectionInput) { + return yield* Effect.try({ + try: () => resolveRemotePairingTarget(input), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + message: cause instanceof Error ? cause.message : "The pairing details are invalid.", + }), + }); + }, +); + +export const preparePairingRegistration = Effect.fn( + "clientRuntime.connection.onboarding.preparePairingRegistration", +)(function* (input: PairingConnectionInput) { + const target = yield* resolvePairingTarget(input); + const presentation = yield* ClientPresentation; + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const access = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const connectionId = `bearer:${descriptor.environmentId}`; + + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: access.access_token, + }), + }); +}); + +export const registerPairingConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerPairingConnection", +)(function* (input: PairingConnectionInput) { + const registration = yield* preparePairingRegistration(input); + const registry = yield* EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +const isBearerCredential = Schema.is(BearerConnectionCredential); +const isBearerProfile = Schema.is(BearerConnectionProfile); + +export const updateBearerConnection = Effect.fn( + "clientRuntime.connection.onboarding.updateBearerConnection", +)(function* (input: BearerConnectionUpdateInput) { + const registry = yield* EnvironmentRegistry; + const credentials = yield* ConnectionCredentialStore; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); + const credential = + entry?.target._tag === "BearerConnectionTarget" + ? yield* credentials.get(entry.target.connectionId) + : Option.none(); + const registration = yield* prepareBearerConnectionUpdate({ + input, + entry: Option.fromUndefinedOr(entry), + credential, + }); + yield* registry.register(registration); +}); + +export const prepareBearerConnectionUpdate = Effect.fn( + "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate", +)(function* (options: { + readonly input: BearerConnectionUpdateInput; + readonly entry: Option.Option; + readonly credential: Option.Option; +}) { + const entry = Option.getOrNull(options.entry); + if ( + entry === undefined || + entry === null || + entry.target._tag !== "BearerConnectionTarget" || + Option.isNone(entry.profile) || + !isBearerProfile(entry.profile.value) + ) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Only saved bearer environments can be edited.", + }); + } + + const credential = options.credential; + if (Option.isNone(credential) || !isBearerCredential(credential.value)) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The saved bearer credential is unavailable.", + }); + } + + const label = options.input.label.trim(); + if (label === "") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Environment label cannot be empty.", + }); + } + const httpBaseUrl = yield* Effect.try({ + try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + message: cause instanceof Error ? cause.message : "The environment URL is invalid.", + }), + }); + const connectionId = entry.target.connectionId; + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: options.input.environmentId, + label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: options.input.environmentId, + label, + httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + }), + credential: credential.value, + }); +}); + +export const prepareSshRegistration = Effect.fn( + "clientRuntime.connection.onboarding.prepareSshRegistration", +)(function* (input: SshConnectionInput) { + const gateway = yield* SshEnvironmentGateway; + const provisioned = yield* gateway.provision(input.target); + const connectionId = `ssh:${provisioned.environmentId}`; + const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; + + return new SshConnectionRegistration({ + target: new SshConnectionTarget({ + environmentId: provisioned.environmentId, + label, + connectionId, + }), + profile: new SshConnectionProfile({ + connectionId, + environmentId: provisioned.environmentId, + label, + target: provisioned.bootstrap.target, + }), + }); +}); + +export const registerSshConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerSshConnection", +)(function* (input: SshConnectionInput) { + const registration = yield* prepareSshRegistration(input); + const registry = yield* EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +export const connectionOnboardingLayer = Layer.effect( + ConnectionOnboarding, + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const presentation = yield* ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + const ssh = yield* SshEnvironmentGateway; + const credentials = yield* ConnectionCredentialStore; + + return ConnectionOnboarding.of({ + registerPairing: (input) => + registerPairingConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + registerSsh: (input) => + registerSshConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(SshEnvironmentGateway, ssh), + ), + updateBearer: (input) => + updateBearerConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(ConnectionCredentialStore, credentials), + ), + }); + }), +); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts new file mode 100644 index 00000000000..d28dd65c18a --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -0,0 +1,184 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + type SupervisorConnectionState, +} from "./model.ts"; +import { + connectionCatalogDisplayUrl, + connectionPhaseMessage, + connectionStatusText, + presentEnvironmentConnection, + presentConnectionState, +} from "./presentation.ts"; + +const TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + connectionId: "connection-1", +}); + +const ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: TARGET.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), +}; + +function supervisorState(overrides: Partial): SupervisorConnectionState { + return { + desired: true, + network: "online", + phase: "connecting", + stage: "preparing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + ...overrides, + }; +} + +describe("connection presentation", () => { + it("preserves profile display information without exposing credentials", () => { + expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); + }); + + it("distinguishes initial connection, reconnect, and retry errors", () => { + expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ + phase: "connecting", + error: null, + traceId: null, + }); + expect( + presentConnectionState( + supervisorState({ + phase: "connecting", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Socket closed.", + traceId: "trace-previous", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Socket closed.", + traceId: "trace-previous", + }); + expect( + presentConnectionState( + supervisorState({ + phase: "backoff", + attempt: 2, + retryAt: 1, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Disconnected.", + traceId: "trace-1", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Disconnected.", + traceId: "trace-1", + }); + }); + + it("preserves the latest failure while the next attempt is active", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connecting", + stage: "opening", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Relay connection timed out.", + traceId: "trace-retry", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Relay connection timed out.", + traceId: "trace-retry", + }); + }); + + it("gives offline status precedence in global messaging", () => { + expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); + }); + + it("combines reconnect progress with the latest failure", () => { + expect( + connectionStatusText({ + phase: "reconnecting", + error: "Relay request timed out.", + traceId: "trace-retry", + }), + ).toBe("Failed to connect. Reconnecting... Reason: Relay request timed out."); + }); + + it("presents the supervisor's offline state without consulting shell state", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + network: "offline", + phase: "offline", + stage: null, + }), + ), + ).toEqual({ + phase: "offline", + error: null, + traceId: null, + }); + }); + + it("presents a connected supervisor snapshot as connected", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connected", + stage: null, + generation: 1, + }), + ), + ).toEqual({ + phase: "connected", + error: null, + traceId: null, + }); + }); + + it("preserves an explicitly available environment while offline", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + desired: false, + network: "offline", + phase: "available", + stage: null, + attempt: 0, + }), + ), + ).toEqual({ + phase: "available", + error: null, + traceId: null, + }); + }); +}); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts new file mode 100644 index 00000000000..ec7687dfe42 --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.ts @@ -0,0 +1,122 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; + +export type EnvironmentConnectionPhase = + | "available" + | "offline" + | "connecting" + | "reconnecting" + | "connected" + | "error"; + +export interface EnvironmentConnectionPresentation { + readonly phase: EnvironmentConnectionPhase; + readonly error: string | null; + readonly traceId: string | null; +} + +export interface EnvironmentPresentation { + readonly entry: ConnectionCatalogEntry; + readonly connection: EnvironmentConnectionPresentation; + readonly serverConfig: ServerConfig | null; +} + +export function presentConnectionState( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + switch (state.phase) { + case "available": + return { phase: "available", error: null, traceId: null }; + case "offline": + return { phase: "offline", error: null, traceId: null }; + case "connecting": + return { + phase: state.attempt <= 1 && state.lastFailure === null ? "connecting" : "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "connected": + return { phase: "connected", error: null, traceId: null }; + case "backoff": + return { + phase: "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "blocked": + return { + phase: "error", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + } +} + +export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { + switch (connection.phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting..."; + case "reconnecting": + return connection.error + ? `Failed to connect. Reconnecting... Reason: ${connection.error}` + : "Reconnecting..."; + case "connected": + return "Connected"; + case "error": + return connection.error + ? `Connection failed. Reason: ${connection.error}` + : "Connection failed"; + } +} + +export function presentEnvironmentConnection( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + return presentConnectionState(state); +} + +export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "SshConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" + ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` + : null; + } +} + +export function connectionPhaseMessage( + phase: EnvironmentConnectionPhase, + label: string, + networkStatus: NetworkStatus, +): string { + if (networkStatus === "offline" || phase === "offline") { + return "You are offline"; + } + switch (phase) { + case "available": + return "Available"; + case "connecting": + return `Connecting to ${label}...`; + case "reconnecting": + return `Reconnecting to ${label}...`; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts new file mode 100644 index 00000000000..f0efe9b0549 --- /dev/null +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -0,0 +1,944 @@ +import { + type DesktopSshEnvironmentTarget, + EnvironmentId, + type OrchestrationShellSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionRegistration, + ConnectionCredentialStore, + ConnectionProfileStore, + PrimaryConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { ConnectionDriver } from "./driver.ts"; +import { + ConnectionTransientError, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import { + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + EnvironmentOwnedDataCleanup, +} from "../platform/persistence.ts"; +import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { EnvironmentSupervisor } from "./supervisor.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const SECOND_TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay"), + label: "Relay environment", +}); +const SECOND_RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay-2"), + label: "Second relay environment", +}); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-bearer"), + label: "Bearer environment", + connectionId: "bearer-connection", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: BEARER_TARGET.environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: "https://bearer.example.test", + wsBaseUrl: "wss://bearer.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); + +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "test", + hostname: "test.example.test", + username: "developer", + port: 22, +}; +const SSH_CONNECTION = new SshConnectionTarget({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + connectionId: "ssh-connection", +}); +const SSH_PROFILE = new SshConnectionProfile({ + connectionId: SSH_CONNECTION.connectionId, + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + target: SSH_TARGET, +}); + +const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +interface SessionControl { + readonly closed: Deferred.Deferred; +} + +const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( + initialTargets: ReadonlyArray, + initialProfiles: ReadonlyArray = [], + initialCredentials: ReadonlyArray = [], + options?: { + readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; + readonly beforeRegistrationRegister?: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly beforeRegistrationRemove?: ( + target: ConnectionTarget, + ) => Effect.Effect; + }, +) { + const storedTargets = yield* Ref.make( + new Map(initialTargets.map((target) => [target.environmentId, target])), + ); + const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); + const cacheClears = yield* Ref.make>([]); + const ownedDataClears = yield* Ref.make>([]); + const sessions = yield* Ref.make>([]); + const releasedSessions = yield* Ref.make(0); + const storedProfiles = yield* Ref.make( + new Map(initialProfiles.map((profile) => [profile.connectionId, profile])), + ); + const profileReadCount = yield* Ref.make(0); + const storedCredentials = yield* Ref.make(new Map(initialCredentials)); + const storedRemoteTokens = yield* Ref.make( + new Map([ + [ + SSH_CONNECTION.environmentId, + new RemoteDpopAccessToken({ + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + endpoint: { + httpBaseUrl: "https://ssh.example.test", + wsBaseUrl: "wss://ssh.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "cached-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint", + }), + ], + ]), + ); + const disconnectedSshTargets = yield* Ref.make>([]); + + const targetStore = ConnectionTargetStore.of({ + list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.set(registration.target.environmentId, registration.target); + return next; + }); + switch (registration._tag) { + case "RelayConnectionRegistration": + return; + case "BearerConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(registration.target.connectionId, registration.credential); + return next; + }); + return; + case "SshConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + } + }), + remove: (target) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") { + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + } + yield* Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + }), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Ref.get(shellCache).pipe( + Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), + ), + saveShell: (environmentId, snapshot) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.set(environmentId, snapshot); + return next; + }), + loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), + saveThread: (_environmentId, _thread) => Effect.void, + removeThread: (_environmentId, _threadId) => Effect.void, + clear: (environmentId) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }).pipe( + Effect.andThen( + Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), + ), + ), + }); + const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), + }); + const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => + Ref.update(profileReadCount, (count) => count + 1).pipe( + Effect.andThen(Ref.get(storedProfiles)), + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (profile) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(profile.connectionId, profile); + return next; + }), + remove: (connectionId) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => + Ref.get(storedCredentials).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (connectionId, credential) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(connectionId, credential); + return next; + }), + remove: (connectionId) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const tokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(storedRemoteTokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const sshGateway = SshEnvironmentGateway.of({ + provision: () => Effect.die(new Error("SSH provisioning is not used.")), + prepare: () => Effect.die(new Error("SSH preparation is not used.")), + disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), + }); + const driver = ConnectionDriver.of({ + connect: (entry, reportProgress) => + Effect.gen(function* () { + const target = entry.target; + const prepared = { + ...PREPARED, + environmentId: target.environmentId, + label: target.label, + target, + }; + yield* reportProgress({ stage: "preparing" }); + yield* reportProgress({ stage: "opening", prepared }); + yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void; + const closed = yield* Deferred.make(); + yield* Ref.update(sessions, (current) => [...current, { closed }]); + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: {} as RpcSession["client"], + initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + ready: Effect.void, + probe: Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession), + () => Ref.update(releasedSessions, (count) => count + 1), + ); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session }; + }), + }); + + const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); + const layer = environmentRegistryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ConnectionTargetStore, targetStore), + Layer.succeed(ConnectionRegistrationStore, registrationStore), + Layer.succeed(ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore, credentialStore), + Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(SshEnvironmentGateway, sshGateway), + Layer.succeed(Connectivity, connectivity), + Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), + Layer.succeed(ConnectionDriver, driver), + cacheLayer, + Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), + ), + ), + ); + + return { + layer, + storedTargets, + shellCache, + cacheClears, + ownedDataClears, + sessions, + releasedSessions, + storedProfiles, + profileReadCount, + storedCredentials, + storedRemoteTokens, + disconnectedSshTargets, + networkStatus, + }; +}); + +function awaitConnectionState( + registry: EnvironmentRegistry["Service"], + environmentId: EnvironmentId, + predicate: (state: SupervisorConnectionState) => boolean, +) { + return Effect.gen(function* () { + const current = yield* registry.state(environmentId); + if (predicate(current)) { + return current; + } + return yield* registry + .stateChanges(environmentId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + }); +} + +describe("EnvironmentRegistry", () => { + it.effect("hydrates connection profiles into catalog entries", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + SSH_CONNECTION.environmentId, + ); + + expect(entry?.target).toEqual(SSH_CONNECTION); + expect(Option.getOrThrow(entry?.profile ?? Option.none())).toEqual(SSH_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("publishes network status changes independently of connection state", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const offline = yield* Effect.forkChild( + SubscriptionRef.changes(registry.networkStatus).pipe( + Stream.filter((status) => status === "offline"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ); + + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + + expect(yield* Fiber.join(offline)).toBe("offline"); + expect(yield* SubscriptionRef.get(registry.networkStatus)).toBe("offline"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts persisted environments independently", () => + Effect.gen(function* () { + const bothLoadsStarted = yield* Deferred.make(); + const releaseLoads = yield* Deferred.make(); + const loadCount = yield* Ref.make(0); + const harness = yield* makeHarness([TARGET, SECOND_TARGET], [], [], { + beforeSessionConnect: () => + Ref.updateAndGet(loadCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(bothLoadsStarted, undefined) : Effect.void, + ), + Effect.andThen(Deferred.await(releaseLoads)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const start = yield* Effect.forkChild(registry.start); + + yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); + yield* Deferred.succeed(releaseLoads, undefined); + yield* Fiber.join(start); + + expect(yield* Ref.get(loadCount)).toBe(2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("exposes the current RPC generation to late query subscribers", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const generation = yield* registry + .runStream( + TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.filterMap((state) => + state.phase === "connected" + ? Result.succeed(state.generation) + : Result.failVoid, + ), + Stream.changes, + ), + ), + ), + ), + ) + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + + expect(generation).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("preserves cached data on connection failure and clears it on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const controls = yield* Ref.get(harness.sessions); + expect(controls).toHaveLength(1); + const active = controls[0]; + expect(active).toBeDefined(); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + const retryFiber = yield* Effect.forkChild( + awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "backoff", + ), + ); + yield* Effect.yieldNow; + yield* Deferred.fail( + active!.closed, + new ConnectionTransientError({ + reason: "transport", + message: "Disconnected.", + }), + ); + yield* Fiber.join(retryFiber); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + yield* registry.remove(TARGET.environmentId); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false); + expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe( + false, + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("persists and starts a newly registered environment", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual( + RELAY_TARGET, + ); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("moves durable streams to a replacement supervisor", () => + Effect.gen(function* () { + const replacement = new RelayConnectionTarget({ + environmentId: RELAY_TARGET.environmentId, + label: "Replacement relay environment", + }); + const harness = yield* makeHarness([RELAY_TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const firstObserved = yield* Deferred.make(); + const secondObserved = yield* Deferred.make(); + const labels = yield* Ref.make>([]); + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const subscription = yield* Effect.forkChild( + registry + .followStream( + RELAY_TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), + ), + ), + ), + ) + .pipe( + Stream.tap((label) => + Ref.updateAndGet(labels, (current) => [...current, label]).pipe( + Effect.flatMap((current) => + current.length === 1 + ? Deferred.succeed(firstObserved, undefined) + : Deferred.succeed(secondObserved, undefined), + ), + ), + ), + Stream.runDrain, + ), + ); + + yield* Deferred.await(firstObserved).pipe(Effect.timeout("1 second")); + yield* registry.register(new RelayConnectionRegistration({ target: replacement })); + yield* Deferred.await(secondObserved).pipe(Effect.timeout("1 second")); + yield* Fiber.interrupt(subscription); + + expect(yield* Ref.get(labels)).toEqual([RELAY_TARGET.label, replacement.label]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("ignores retry signals for environments that are no longer registered", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.retryNow(EnvironmentId.make("removed-environment")); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all relay-owned data without touching non-cloud connections", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [RELAY_TARGET, SECOND_RELAY_TARGET, BEARER_TARGET], + [BEARER_PROFILE], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.removeRelayEnvironments(); + + const targets = yield* Ref.get(harness.storedTargets); + expect(targets.has(RELAY_TARGET.environmentId)).toBe(false); + expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false); + expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET); + expect(yield* Ref.get(harness.cacheClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).has(BEARER_TARGET.environmentId), + ).toBe(true); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("keeps the runtime registered when durable removal fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + beforeRegistrationRemove: () => + Effect.fail( + new ConnectionPersistenceError({ + operation: "remove-connection", + message: "Storage is unavailable.", + }), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const error = yield* Effect.flip(registry.removeRelayEnvironments()); + + expect(error._tag).toBe("ConnectionPersistenceError"); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe( + true, + ); + expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.cacheClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts a newly paired bearer environment without re-reading its profile", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.register( + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + yield* awaitConnectionState( + registry, + BEARER_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect(yield* Ref.get(harness.profileReadCount)).toBe(0); + expect( + Option.getOrThrow( + (yield* SubscriptionRef.get(registry.entries)).get(BEARER_TARGET.environmentId) + ?.profile ?? Option.none(), + ), + ).toEqual(BEARER_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts platform environments without persisting or removing them", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); + expect(error._tag).toBe("PlatformEnvironmentRemovalError"); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("gives a primary platform registration precedence over persisted registrations", () => + Effect.gen(function* () { + const shadowedTarget = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: "Shadowed relay environment", + }); + const harness = yield* makeHarness([shadowedTarget]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + + yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget })); + + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("rechecks platform ownership after waiting for the environment lease", () => + Effect.gen(function* () { + const registrationStarted = yield* Deferred.make(); + const continueRegistration = yield* Deferred.make(); + const shadowedTarget = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: "Shadowed relay environment", + }); + const harness = yield* makeHarness([], [], [], { + beforeRegistrationRegister: () => + Deferred.succeed(registrationStarted, undefined).pipe( + Effect.andThen(Deferred.await(continueRegistration)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const persistedRegistration = yield* registry + .register(new RelayConnectionRegistration({ target: shadowedTarget })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registrationStarted); + + const platformRegistration = yield* registry + .registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + const removal = yield* Effect.flip(registry.remove(TARGET.environmentId)).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.succeed(continueRegistration, undefined); + yield* Fiber.join(persistedRegistration); + yield* Fiber.join(platformRegistration); + const error = yield* Fiber.join(removal); + + expect(error._tag).toBe("PlatformEnvironmentRemovalError"); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("does not reacquire a runtime while its registration is being removed", () => + Effect.gen(function* () { + const removalStarted = yield* Deferred.make(); + const continueRemoval = yield* Deferred.make(); + const harness = yield* makeHarness([TARGET], [], [], { + beforeRegistrationRemove: () => + Deferred.succeed(removalStarted, undefined).pipe( + Effect.andThen(Deferred.await(continueRemoval)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const removal = yield* Effect.forkChild(registry.remove(TARGET.environmentId)); + yield* Deferred.await(removalStarted); + + const stateLookup = yield* Effect.forkChild( + Effect.flip(registry.state(TARGET.environmentId)), + ); + yield* Effect.yieldNow; + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + + yield* Deferred.succeed(continueRemoval, undefined); + yield* Fiber.join(removal); + const error = yield* Fiber.join(stateLookup); + expect(error._tag).toBe("EnvironmentNotRegisteredError"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* registry.registerPlatform(registration); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.registerPlatform(registration); + + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all owned SSH state only on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [SSH_CONNECTION], + [SSH_PROFILE], + [ + [ + SSH_CONNECTION.connectionId, + new BearerConnectionCredential({ token: "temporary-token" }), + ], + ], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* registry.remove(SSH_CONNECTION.environmentId); + + expect((yield* Ref.get(harness.storedProfiles)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedCredentials)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedRemoteTokens)).has(SSH_CONNECTION.environmentId)).toBe( + false, + ); + expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts new file mode 100644 index 00000000000..7560d06f50f --- /dev/null +++ b/packages/client-runtime/src/connection/registry.ts @@ -0,0 +1,576 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { + type ConnectionCatalogEntry, + type ConnectionRegistration, + ConnectionProfileStore, + type PrimaryConnectionRegistration, + SshConnectionProfile, + connectionRegistrationCatalogEntry, +} from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import type { + ConnectionAttemptError, + ConnectionTarget, + NetworkStatus, + SupervisorConnectionState, +} from "./model.ts"; +import { + type ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + EnvironmentOwnedDataCleanup, +} from "../platform/persistence.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, + makeEnvironmentSupervisor, +} from "./supervisor.ts"; +import { ConnectionDriver } from "./driver.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const isSshConnectionProfile = Schema.is(SshConnectionProfile); + +export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass()( + "EnvironmentNotRegisteredError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( + "PlatformEnvironmentRemovalError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export interface EnvironmentRegistryService { + readonly entries: SubscriptionRef.SubscriptionRef< + ReadonlyMap + >; + readonly networkStatus: SubscriptionRef.SubscriptionRef; + readonly start: Effect.Effect; + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + | ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; + readonly removeRelayEnvironments: () => Effect.Effect< + void, + ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError + >; + readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + readonly state: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly stateChanges: ( + environmentId: EnvironmentId, + ) => Stream.Stream; + readonly run: ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ) => Effect.Effect>; + readonly runStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; + readonly followStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; +} + +export class EnvironmentRegistry extends Context.Service< + EnvironmentRegistry, + EnvironmentRegistryService +>()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} + +interface EnvironmentServiceScope { + readonly entry: ConnectionCatalogEntry; + readonly supervisor: EnvironmentSupervisorService; + readonly scope: Scope.Closeable; +} + +const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { + const storage = yield* ConnectionTargetStore; + const registrations = yield* ConnectionRegistrationStore; + const cache = yield* EnvironmentCacheStore; + const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; + const profiles = yield* ConnectionProfileStore; + const connectivity = yield* Connectivity; + const driver = yield* ConnectionDriver; + const wakeups = yield* ConnectionWakeups; + const ssh = yield* SshEnvironmentGateway; + const persistedTargets = yield* storage.list; + const initialEntries = new Map( + yield* Effect.forEach( + persistedTargets, + Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + return [ + target.environmentId, + { target, profile } satisfies ConnectionCatalogEntry, + ] as const; + }), + { concurrency: "unbounded" }, + ), + ); + const entries = + yield* SubscriptionRef.make>(initialEntries); + const networkStatus = yield* SubscriptionRef.make(yield* connectivity.status); + const serviceScopes = yield* SubscriptionRef.make< + ReadonlyMap + >(new Map()); + const platformEnvironmentIds = yield* Ref.make>(new Set()); + const persistedTargetsByEnvironment = yield* Ref.make< + ReadonlyMap + >(new Map(persistedTargets.map((target) => [target.environmentId, target]))); + interface LeaseLock { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; + } + + const leaseLocks = yield* Ref.make>(new Map()); + const leaseLocksGuard = yield* Semaphore.make(1); + const started = yield* Ref.make(false); + + const withLeaseLock = ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + leaseLocksGuard.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(leaseLocks); + const existing = current.get(environmentId); + if (existing !== undefined) { + yield* Ref.set( + leaseLocks, + new Map(current).set(environmentId, { + semaphore: existing.semaphore, + users: existing.users + 1, + }), + ); + return existing.semaphore; + } + const semaphore = yield* Semaphore.make(1); + yield* Ref.set(leaseLocks, new Map(current).set(environmentId, { semaphore, users: 1 })); + return semaphore; + }), + ), + (semaphore) => semaphore.withPermits(1)(effect), + (semaphore) => + leaseLocksGuard.withPermits(1)( + Ref.update(leaseLocks, (current) => { + const existing = current.get(environmentId); + if (existing === undefined || existing.semaphore !== semaphore) { + return current; + } + const next = new Map(current); + if (existing.users === 1) { + next.delete(environmentId); + } else { + next.set(environmentId, { + semaphore, + users: existing.users - 1, + }); + } + return next; + }), + ), + ).pipe(Effect.withSpan("EnvironmentRegistry.withLeaseLock")); + + const getEntry = Effect.fn("EnvironmentRegistry.getEntry")(function* ( + environmentId: EnvironmentId, + ) { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + if (entry === undefined) { + return yield* new EnvironmentNotRegisteredError({ + environmentId, + message: `Environment ${environmentId} is not registered.`, + }); + } + return entry; + }); + + const closeServiceScope = Effect.fn("EnvironmentRegistry.closeServiceScope")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* SubscriptionRef.get(serviceScopes); + const lease = current.get(environmentId); + if (lease === undefined) { + return; + } + const next = new Map(current); + next.delete(environmentId); + yield* SubscriptionRef.set(serviceScopes, next); + yield* Scope.close(lease.scope, Exit.void); + }); + + const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( + (entry: ConnectionCatalogEntry) => + Effect.uninterruptible( + Effect.gen(function* () { + const environmentId = entry.target.environmentId; + const scope = yield* Scope.make(); + const supervisor = yield* makeEnvironmentSupervisor(entry, { + initiallyDesired: false, + }).pipe( + Effect.provideService(Connectivity, connectivity), + Effect.provideService(ConnectionDriver, driver), + Effect.provideService(ConnectionWakeups, wakeups), + Scope.provide(scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + yield* supervisor.connect; + yield* SubscriptionRef.update(serviceScopes, (current) => { + const next = new Map(current); + next.set(environmentId, { entry, supervisor, scope }); + return next; + }); + return supervisor; + }), + ), + ); + + const acquireSupervisor = Effect.fn("EnvironmentRegistry.acquireSupervisor")(function* ( + environmentId: EnvironmentId, + ) { + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* getEntry(environmentId); + const existing = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (existing !== undefined) { + if (Equal.equals(existing.entry, entry)) { + return existing.supervisor; + } + yield* closeServiceScope(environmentId); + } + return yield* createServiceScope(entry); + }), + ); + }); + + const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < + A, + E, + R, + >(environmentId: EnvironmentId, effect: Effect.Effect) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); + }); + + const runStream: EnvironmentRegistryService["runStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.map((supervisor) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + ), + ), + ); + + const followStream: EnvironmentRegistryService["followStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(entries)), + SubscriptionRef.changes(entries), + ).pipe( + Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + Stream.changes, + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: () => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.match({ + onFailure: () => Stream.empty, + onSuccess: (supervisor) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + }), + ), + ), + }), + ), + ); + + const start = Effect.gen(function* () { + if (yield* Ref.getAndSet(started, true)) { + return; + } + yield* Effect.forEach( + persistedTargets, + (target) => + acquireSupervisor(target.environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }).pipe(Effect.withSpan("EnvironmentRegistry.start")); + + const installEntryLocked = Effect.fn("EnvironmentRegistry.installEntryLocked")(function* ( + entry: ConnectionCatalogEntry, + options?: { readonly retainEquivalentRuntime?: boolean }, + ) { + const target = entry.target; + const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); + const existingScope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); + if ( + options?.retainEquivalentRuntime === true && + previous !== undefined && + Equal.equals(previous, entry) && + existingScope !== undefined && + Equal.equals(existingScope.entry, entry) + ) { + return; + } + + yield* closeServiceScope(target.environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.set(target.environmentId, entry); + return next; + }); + yield* createServiceScope(entry); + }); + + const register = Effect.fn("EnvironmentRegistry.register")(function* ( + registration: ConnectionRegistration, + ) { + const entry = connectionRegistrationCatalogEntry(registration); + const environmentId = entry.target.environmentId; + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return; + } + yield* registrations.register(registration); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.set(environmentId, registration.target); + return next; + }); + yield* installEntryLocked(entry); + }), + ); + }); + + const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( + registration: PrimaryConnectionRegistration, + ) { + const entry = connectionRegistrationCatalogEntry(registration); + const target = entry.target; + yield* withLeaseLock( + target.environmentId, + Effect.gen(function* () { + yield* Ref.update(platformEnvironmentIds, (current) => { + const next = new Set(current); + next.add(target.environmentId); + return next; + }); + + const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( + target.environmentId, + ); + if (persistedTarget !== undefined) { + yield* registrations.remove(persistedTarget).pipe( + Effect.tap(() => + Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }), + ), + Effect.catch((error) => + Effect.logWarning( + "Could not remove a persisted registration shadowed by the primary environment.", + { + environmentId: target.environmentId, + error, + }, + ), + ), + ); + } + + yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); + }), + ); + }); + + const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return yield* new PlatformEnvironmentRemovalError({ + environmentId, + message: "Platform-managed environments cannot be removed.", + }); + } + const target = (yield* getEntry(environmentId)).target; + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + + yield* registrations.remove(target); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ownedDataCleanup.clear(environmentId), + ], + { concurrency: "unbounded", discard: true }, + ); + + if ( + target._tag === "SshConnectionTarget" && + Option.isSome(profile) && + isSshConnectionProfile(profile.value) + ) { + yield* ssh.disconnect(profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + }), + ); + }); + + const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( + function* () { + const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId); + + yield* Effect.forEach( + relayEnvironmentIds, + (environmentId) => + remove(environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }, + ); + + const retryNow = (environmentId: EnvironmentId) => + acquireSupervisor(environmentId).pipe( + Effect.flatMap((supervisor) => supervisor.retryNow), + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.withSpan("EnvironmentRegistry.retryNow"), + ); + const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* SubscriptionRef.get(supervisor.state); + }); + const stateChanges = (environmentId: EnvironmentId) => + followStream( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(serviceScopes).pipe( + Effect.flatMap((current) => + Effect.forEach(current.values(), (lease) => Scope.close(lease.scope, Exit.void), { + concurrency: "unbounded", + discard: true, + }), + ), + ), + ); + yield* connectivity.changes.pipe( + Stream.runForEach((status) => SubscriptionRef.set(networkStatus, status)), + Effect.forkScoped, + ); + + return EnvironmentRegistry.of({ + entries, + networkStatus, + start, + register, + registerPlatform, + remove, + removeRelayEnvironments, + retryNow, + state, + stateChanges, + run, + runStream, + followStream, + }); +}); + +export const environmentRegistryLayer = Layer.effect( + EnvironmentRegistry, + makeEnvironmentRegistry(), +); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts new file mode 100644 index 00000000000..31f75bf4bdc --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -0,0 +1,423 @@ +import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Tracer from "effect/Tracer"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + ManagedRelayRequestTimeoutError, +} from "../relay/managedRelay.ts"; +import { ConnectionResolver } from "./resolver.ts"; +import { connectionResolverLayer } from "./resolver.ts"; +import { + CloudSession, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "../platform/capabilities.ts"; +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + ConnectionCredentialStore, + ConnectionProfileStore, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "development", + hostname: "development.example.test", + username: "developer", + port: 22, +}; + +function catalogEntry( + target: ConnectionTarget, + profile: Option.Option = Option.none(), +): ConnectionCatalogEntry { + return { target, profile }; +} + +function unsupported(name: string): Effect.Effect { + return Effect.die(new Error(`Unexpected relay call: ${name}`)); +} + +function collectingTracer(spans: Array): Tracer.Tracer { + return Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span.name); + }; + return span; + }, + }); +} + +function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { + return ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => unsupported("listEnvironments"), + listDevices: () => unsupported("listDevices"), + createEnvironmentLinkChallenge: () => unsupported("createEnvironmentLinkChallenge"), + linkEnvironment: () => unsupported("linkEnvironment"), + unlinkEnvironment: () => unsupported("unlinkEnvironment"), + getEnvironmentStatus: () => unsupported("getEnvironmentStatus"), + connectEnvironment, + registerDevice: () => unsupported("registerDevice"), + unregisterDevice: () => unsupported("unregisterDevice"), + registerLiveActivity: () => unsupported("registerLiveActivity"), + resetTokenCache: Effect.void, + }); +} + +const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { + readonly profiles?: ReadonlyArray; + readonly credentials?: ReadonlyArray; + readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; + readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; + readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; +}) => { + const profiles = new Map( + (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), + ); + const credentials = new Map(options?.credentials ?? []); + + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), + put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), + remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), + put: (connectionId, credential) => + Effect.sync(() => void credentials.set(connectionId, credential)), + remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), + }); + const remote = RemoteEnvironmentAuthorization.of({ + authorizeBearer: + options?.authorizeBearer ?? + ((input) => + Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "Authorized bearer environment", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=bearer", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + })), + authorizeDpop: + options?.authorizeDpop ?? + ((input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Authorized relay environment", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + )), + }); + const ssh = SshEnvironmentGateway.of({ + provision: () => Effect.die("unused"), + prepare: + options?.prepareSsh ?? + (() => + Effect.succeed({ + bootstrap: { + target: SSH_TARGET, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + })), + disconnect: () => Effect.void, + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore, credentialStore), + Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), + Layer.succeed( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), + ), + Layer.succeed(RemoteEnvironmentAuthorization, remote), + Layer.succeed(SshEnvironmentGateway, ssh), + Layer.succeed( + ManagedRelayClient, + relayClient( + options?.connectEnvironment ?? + ((input) => + Effect.succeed({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + })), + ), + ), + ); + + return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); +}); + +describe("ConnectionResolver", () => { + it.effect("prepares a primary environment without remote capabilities", () => + Effect.gen(function* () { + const brokerLayer = yield* makeDependencies(); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + expect(yield* broker.prepare(catalogEntry(target))).toEqual({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + socketUrl: "ws://127.0.0.1:3777/ws", + httpAuthorization: null, + target, + }); + }), + ); + + it.effect("uses the registered bearer profile without re-reading the profile store", () => + Effect.gen(function* () { + const bearerInputs = yield* Ref.make>([]); + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Saved", + connectionId: "saved-1", + }); + const profile = new BearerConnectionProfile({ + connectionId: "saved-1", + environmentId: ENVIRONMENT_ID, + label: "Saved", + httpBaseUrl: ENDPOINT.httpBaseUrl, + wsBaseUrl: ENDPOINT.wsBaseUrl, + }); + const brokerLayer = yield* makeDependencies({ + credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], + authorizeBearer: (input) => + Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Saved", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=ticket", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=ticket"); + expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); + }), + ); + + it.effect("brokers relay credentials with the current cloud session and device identity", () => + Effect.gen(function* () { + const relayInputs = yield* Ref.make< + ReadonlyArray<{ + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly deviceId?: string; + }> + >([]); + const bootstrapCredentials = yield* Ref.make>([]); + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: (input) => + Ref.update(relayInputs, (values) => [ + ...values, + { + clerkToken: input.clerkToken, + scopes: input.scopes, + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + }, + ]).pipe( + Effect.as({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + }), + ), + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.tap((bootstrap) => + Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]), + ), + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); + expect(yield* Ref.get(relayInputs)).toEqual([ + { + clerkToken: "clerk-session", + scopes: [RelayEnvironmentConnectScope], + deviceId: "device-1", + }, + ]); + expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); + }), + ); + + it.effect("exports the complete relay authorization flow through the product tracer", () => + Effect.gen(function* () { + const userSpans: Array = []; + const productSpans: Array = []; + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + Effect.withSpan("test.remote.authorizeDpop"), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + yield* broker + .prepare(catalogEntry(target)) + .pipe( + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), + ); + + expect(productSpans).toContain("clientRuntime.connection.broker.relay"); + expect(productSpans).toContain("test.remote.authorizeDpop"); + expect(userSpans).toContain("clientRuntime.connection.broker.prepare"); + expect(userSpans).not.toContain("test.remote.authorizeDpop"); + }), + ); + + it.effect("delegates SSH launch to the platform gateway before remote authorization", () => + Effect.gen(function* () { + const preparedTargets = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: "ssh-1", + environmentId: ENVIRONMENT_ID, + label: "SSH", + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + prepareSsh: (input) => + Ref.update(preparedTargets, (values) => [...values, input.target]).pipe( + Effect.as({ + bootstrap: { + target: input.target, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=bearer"); + expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); + }), + ); + + it.effect("classifies relay request timeouts as retryable connection failures", () => + Effect.gen(function* () { + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Relay timed out.", + cause: new ManagedRelayRequestTimeoutError({ + message: "Relay timed out.", + }), + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "timeout" }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts new file mode 100644 index 00000000000..6eb0027e3a8 --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.ts @@ -0,0 +1,257 @@ +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { ManagedRelayClient } from "../relay/managedRelay.ts"; +import { + CloudSession, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + ConnectionCredentialStore, + ConnectionProfileStore, + SshConnectionProfile, +} from "./catalog.ts"; +import { + credentialMissingError, + environmentMismatchError, + mapManagedRelayError, + profileMissingError, +} from "./errors.ts"; +import type { + BearerConnectionTarget, + ConnectionTarget, + PreparedConnection, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "./model.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; + +export class ConnectionResolver extends Context.Service< + ConnectionResolver, + { + readonly prepare: ( + entry: ConnectionCatalogEntry, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} + +const isBearerProfile = Schema.is(BearerConnectionProfile); +const isSshProfile = Schema.is(SshConnectionProfile); +const isBearerCredential = Schema.is(BearerConnectionCredential); + +function primarySocketUrl(target: PrimaryConnectionTarget): string { + const url = new URL(target.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + return url.toString(); +} + +const primaryBroker = Effect.fn("clientRuntime.connection.broker.primary")( + (target: PrimaryConnectionTarget) => + Effect.succeed({ + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: primarySocketUrl(target), + httpAuthorization: null, + target, + } satisfies PreparedConnection), +); + +const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { + const credentials = yield* ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( + entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isBearerProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${target.connectionId} is not a bearer connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const credential = yield* credentials.get(target.connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(credentialMissingError(target.connectionId)), + onSome: Effect.succeed, + }), + ), + ); + if (!isBearerCredential(credential)) { + return yield* credentialMissingError(target.connectionId); + } + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + bearerToken: credential.token, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { + const relay = yield* ManagedRelayClient; + const session = yield* CloudSession; + const identity = yield* RelayDeviceIdentity; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fnUntraced( + function* (target: RelayConnectionTarget) { + const authorized = yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + obtainBootstrap: Effect.gen(function* () { + const clerkToken = yield* session.clerkToken.pipe( + Effect.withSpan("relay.connection.cloudSessionToken.resolve"), + ); + const deviceId = yield* identity.deviceId.pipe( + Effect.withSpan("relay.connection.deviceIdentity.resolve"), + ); + const connected = yield* relay + .connectEnvironment({ + clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: target.environmentId, + ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + }) + .pipe(Effect.mapError(mapManagedRelayError)); + if (connected.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: connected.environmentId, + }); + } + return connected; + }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }, + Effect.withSpan("clientRuntime.connection.broker.relay"), + withRelayClientTracing, + ); +}); + +const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { + const profiles = yield* ConnectionProfileStore; + const ssh = yield* SshEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( + entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isSshProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${target.connectionId} is not an SSH connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const prepared = yield* ssh.prepare({ + connectionId: target.connectionId, + expectedEnvironmentId: target.environmentId, + target: profile.target, + }); + yield* profiles.put( + new SshConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + target: prepared.bootstrap.target, + }), + ); + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: prepared.bootstrap.httpBaseUrl, + wsBaseUrl: prepared.bootstrap.wsBaseUrl, + bearerToken: prepared.bearerToken, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +export const connectionResolverLayer = Layer.effect( + ConnectionResolver, + Effect.gen(function* () { + const bearer = yield* makeBearerBroker(); + const relay = yield* makeRelayBroker(); + const ssh = yield* makeSshBroker(); + + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const target: ConnectionTarget = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + switch (target._tag) { + case "PrimaryConnectionTarget": + return yield* primaryBroker(target); + case "BearerConnectionTarget": + return yield* bearer({ ...entry, target }); + case "RelayConnectionTarget": + return yield* relay(target); + case "SshConnectionTarget": + return yield* ssh({ ...entry, target }); + } + }); + + return ConnectionResolver.of({ prepare }); + }), +); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts new file mode 100644 index 00000000000..1ebd2812c92 --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -0,0 +1,847 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + type ConnectionAttemptError, + type ConnectionTarget, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { makeEnvironmentSupervisor } from "./supervisor.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, +}); + +const TARGET_ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.none(), +}; + +const RELAY_ENTRY: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), +}; + +const PREPARED_CONNECTION: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; + +function transient(message = "Connection failed.") { + return new ConnectionTransientError({ + reason: "transport", + message, + }); +} + +function blocked(message = "Authentication required.") { + return new ConnectionBlockedError({ + reason: "authentication", + message, + }); +} + +function awaitState( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + return SubscriptionRef.changes(state).pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); +} + +const eventuallyState = Effect.fn("TestConnectionHarness.eventuallyState")(function* ( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + let lastState = yield* SubscriptionRef.get(state); + for (let iteration = 0; iteration < 100; iteration += 1) { + lastState = yield* SubscriptionRef.get(state); + if (predicate(lastState)) { + return lastState; + } + yield* Effect.yieldNow; + } + return yield* Effect.die( + new Error( + `Expected supervisor state was not observed. Last state: phase=${lastState.phase}, stage=${lastState.stage ?? "none"}, attempt=${lastState.attempt}, generation=${lastState.generation}`, + ), + ); +}); + +const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: { + readonly networkStatus?: NetworkStatus; + readonly prepare?: ( + attempt: number, + target: ConnectionTarget, + ) => Effect.Effect; + readonly ready?: (attempt: number) => Effect.Effect; + readonly probe?: (attempt: number) => Effect.Effect; +}) { + const networkStatus = yield* SubscriptionRef.make( + options?.networkStatus ?? "online", + ); + const prepareCount = yield* Ref.make(0); + const sessionCount = yield* Ref.make(0); + const releaseCount = yield* Ref.make(0); + const wakeups = yield* SubscriptionRef.make<{ + readonly sequence: number; + readonly reason: "application-active" | "credentials-changed"; + }>({ + sequence: 0, + reason: "application-active", + }); + const closedSessions = yield* Ref.make< + ReadonlyArray> + >([]); + + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + + const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) { + const attempt = yield* Ref.updateAndGet(prepareCount, (count) => count + 1); + if (options?.prepare) { + return yield* options.prepare(attempt, target); + } + return PREPARED_CONNECTION; + }); + + const connect = Effect.fn("TestConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* prepare(target); + yield* reportProgress({ stage: "opening", prepared }); + + const attempt = yield* Ref.updateAndGet(sessionCount, (count) => count + 1); + const closed = yield* Deferred.make(); + yield* Ref.update(closedSessions, (sessions) => [...sessions, closed]); + + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: TEST_RPC_CLIENT, + initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + ready: options?.ready?.(attempt) ?? Effect.void, + probe: options?.probe?.(attempt) ?? Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession), + () => Ref.update(releaseCount, (count) => count + 1), + ); + + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: SubscriptionRef.changes(wakeups).pipe( + Stream.drop(1), + Stream.map((event) => event.reason), + ), + }), + ), + Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), + ); + + return { + dependencies, + prepareCount, + sessionCount, + releaseCount, + setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), + wake: (reason: "application-active" | "credentials-changed") => + SubscriptionRef.update(wakeups, (event) => ({ + sequence: event.sequence + 1, + reason, + })), + closeLatestSession: Effect.fn("TestConnectionHarness.closeLatestSession")(function* ( + error = transient("Session closed."), + ) { + const sessions = yield* Ref.get(closedSessions); + const latest = sessions.at(-1); + if (latest) { + yield* Deferred.fail(latest, error); + } + }), + }; +}); + +describe("EnvironmentSupervisor", () => { + it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span); + }; + return span; + }, + }); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe( + Effect.provide(harness.dependencies), + Effect.provideService(RelayClientTracer, Option.some(tracer)), + ); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); + expect(firstAttempt).toBeDefined(); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); + expect(attempts).toHaveLength(2); + expect(attempts[0]?.traceId).not.toBe(attempts[1]?.traceId); + expect(attempts[1]?.links.map((link) => link.span.spanId)).toContain(attempts[0]?.spanId); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not attempt a connection until it is desired", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("available"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + }), + ); + + it.effect("does not let the initial connect signal cancel the first attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + yield* supervisor.connect; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }), + ); + + it.effect("waits while offline and connects immediately when the network returns", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ networkStatus: "offline" }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + + yield* harness.setNetworkStatus("online"); + const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(ready).toMatchObject({ + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + lastFailure: null, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + }), + ); + + it.effect("retries forever with exponential backoff capped at sixteen seconds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + yield* TestClock.adjust(delay); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === index + 2, + ); + } + + expect(yield* Ref.get(harness.prepareCount)).toBe(7); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps the latest failure visible throughout the next connection attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("1 second"); + + const retrying = yield* awaitState( + supervisor.state, + (state) => + state.phase === "connecting" && state.stage === "preparing" && state.attempt === 2, + ); + expect(retrying).toMatchObject({ + phase: "connecting", + stage: "preparing", + attempt: 2, + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Relay connection timed out.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("retries when a session never becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + ready: () => Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("14 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); + + yield* TestClock.adjust("1 second"); + const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + + expect(retrying).toMatchObject({ + phase: "backoff", + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("interrupts and releases a connection attempt when setup times out", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "preparing", + ); + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("converts unexpected driver defects into retryable failures", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Effect.die(new Error("Native transport defect.")) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + const failed = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(failed).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Test environment connection failed unexpectedly.", + }, + }); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("explicit retry interrupts the current backoff", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("keeps blocked failures idle until an external signal requests another attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* TestClock.adjust("1 hour"); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases a live session while offline and starts a new generation when online", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.setNetworkStatus("offline"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + yield* harness.setNetworkStatus("online"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }), + ); + + it.effect("retries a blocked connection when platform credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* harness.wake("credentials-changed"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("does not let platform wakeups reset an in-flight attempt", () => + Effect.gen(function* () { + const firstAttemptStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: () => + Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(firstAttemptStarted); + yield* Effect.all( + [ + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("treats an involuntary session close as transient and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(Option.isSome(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps escalating backoff when a newly opened session flaps", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + yield* harness.closeLatestSession(); + const secondFailure = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + expect(secondFailure.retryAt).not.toBeNull(); + + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps a healthy session when the application becomes active", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const probeCalled = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => + Ref.update(probeCount, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(probeCalled, undefined)), + ), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeCalled); + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("reconnects when the foreground liveness probe fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out a stalled foreground liveness probe and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("15 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", + ); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("honors an explicit disconnect while a foreground probe is stalled", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeStarted); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("does not churn a healthy session when credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("credentials-changed"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("releases and reconnects a relay session when credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("credentials-changed"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("interrupts relay setup when credentials change", () => + Effect.gen(function* () { + const firstAttemptStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(firstAttemptStarted); + yield* harness.wake("credentials-changed"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + }), + ); + + it.effect("explicit disconnect releases the session and returns to available", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }), + ); + + it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* Effect.all( + [ + supervisor.disconnect, + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts new file mode 100644 index 00000000000..56ebe0efaf4 --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -0,0 +1,724 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as Tracer from "effect/Tracer"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; +import { + type ConnectionAttemptError, + type ConnectionTarget, + ConnectionTransientError, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; + +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; +const CONNECTION_PROBE_TIMEOUT = "15 seconds"; +const BACKOFF_RESET_AFTER_MS = 30_000; + +interface SupervisorIntent { + readonly desired: boolean; + readonly network: NetworkStatus; +} + +type SupervisorSignal = + | { readonly _tag: "ConnectRequested" } + | { readonly _tag: "DisconnectRequested" } + | { readonly _tag: "RetryRequested" } + | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; + +interface PendingRetryTrace { + readonly previousAttempt: Tracer.Span; + readonly failureCount: number; + readonly delayMs: number; + readonly reason: ConnectionAttemptError["reason"]; +} + +interface TracedAttemptFailure { + readonly error: ConnectionAttemptError; + readonly attemptSpan: Option.Option; +} + +type AttemptOutcome = + | { + readonly _tag: "Interrupted"; + readonly established: boolean; + readonly stable: boolean; + } + | { + readonly _tag: "Failure"; + readonly established: boolean; + readonly stable: boolean; + readonly failure: TracedAttemptFailure; + }; + +type EstablishmentEvent = + | { + readonly _tag: "Completed"; + readonly exit: Exit.Exit< + { + readonly attemptSpan: Option.Option; + readonly lease: EnvironmentConnectionLease; + }, + TracedAttemptFailure + >; + } + | { readonly _tag: "Interrupted" } + | { readonly _tag: "TimedOut" }; + +function exitUnlessInterrupted( + effect: Effect.Effect, +): Effect.Effect, never, R> { + return Effect.matchCauseEffect(effect, { + onFailure: (cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Exit.failCause(cause)), + onSuccess: (value) => Effect.succeed(Exit.succeed(value)), + }); +} + +export interface EnvironmentSupervisorOptions { + readonly initiallyDesired?: boolean; +} + +export interface EnvironmentSupervisorService { + readonly target: ConnectionTarget; + readonly state: SubscriptionRef.SubscriptionRef; + readonly session: SubscriptionRef.SubscriptionRef>; + readonly prepared: SubscriptionRef.SubscriptionRef>; + readonly connect: Effect.Effect; + readonly disconnect: Effect.Effect; + readonly retryNow: Effect.Effect; +} + +function retryDelayMs(failureCount: number): number { + return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +} + +function annotateTarget(target: ConnectionTarget) { + return Effect.annotateCurrentSpan({ + "environment.id": target.environmentId, + "environment.label": target.label, + "environment.target.kind": target._tag, + }); +} + +function availableState(intent: SupervisorIntent, generation: number): SupervisorConnectionState { + return { + desired: false, + network: intent.network, + phase: "available", + stage: null, + attempt: 0, + generation, + lastFailure: null, + retryAt: null, + }; +} + +function offlineState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "offline", + stage: null, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function connectingState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, + stage: SupervisorConnectionState["stage"] = "preparing", +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "connecting", + stage, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function failureFromExit( + target: ConnectionTarget, + exit: Exit.Exit, + established: boolean, + stable: boolean, +): AttemptOutcome { + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { + return { _tag: "Interrupted", established, stable }; + } + const typedFailure = exit.cause.reasons.find(Cause.isFailReason); + if (typedFailure) { + return { + _tag: "Failure", + established, + stable, + failure: typedFailure.error, + }; + } + return { + _tag: "Failure", + established, + stable, + failure: { + error: new ConnectionTransientError({ + reason: "transport", + message: `${target.label} connection failed unexpectedly.`, + }), + attemptSpan: Option.none(), + }, + }; +} + +export class EnvironmentSupervisor extends Context.Service< + EnvironmentSupervisor, + EnvironmentSupervisorService +>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { + static layer( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, + ): Layer.Layer< + EnvironmentSupervisor, + never, + Connectivity | ConnectionDriver | ConnectionWakeups + > { + return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); + } +} + +export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Effect.fn.Return< + EnvironmentSupervisorService, + never, + Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups +> { + const target = entry.target; + yield* annotateTarget(target); + + const connectivity = yield* Connectivity; + const driver = yield* ConnectionDriver; + const wakeups = yield* ConnectionWakeups; + const initialIntent: SupervisorIntent = { + desired: options?.initiallyDesired ?? false, + network: yield* connectivity.status, + }; + const intent = yield* Ref.make(initialIntent); + const signals = yield* Queue.unbounded(); + const state = yield* SubscriptionRef.make( + !initialIntent.desired + ? availableState(initialIntent, 0) + : initialIntent.network === "offline" + ? offlineState(initialIntent, 0, 0, null) + : connectingState(initialIntent, 0, 1, null), + ); + const session = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>(Option.none()); + + const clearLease = Effect.all( + [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], + { discard: true }, + ); + + const setState = Effect.fn("EnvironmentSupervisor.setState")(function* ( + next: SupervisorConnectionState, + ) { + yield* SubscriptionRef.set(state, next); + }); + + const signal = Effect.fn("EnvironmentSupervisor.signal")(function* (next: SupervisorSignal) { + yield* Queue.offer(signals, next); + }); + + const logManagedRelayAccountChange = Effect.logInfo( + "Managed relay account changed; restarting the environment connection.", + ).pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + }), + ); + + const reportProgress = Effect.fn("EnvironmentSupervisor.reportProgress")(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + progress: ConnectionDriverProgress, + ) { + if ("prepared" in progress) { + yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); + } + yield* setState( + connectingState(yield* Ref.get(intent), generation, attempt, lastFailure, progress.stage), + ); + }); + + const establishConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + ) { + return yield* driver.connect(entry, (progress) => + reportProgress(attempt, generation, lastFailure, progress), + ); + }); + + const traceRelayEstablishment = ( + effect: Effect.Effect, + attempt: number, + generation: number, + pendingRetry: Option.Option, + ) => { + const traced = Effect.gen(function* () { + const attemptSpan = yield* Effect.currentSpan.pipe(Effect.orDie); + yield* annotateTarget(target); + yield* Effect.annotateCurrentSpan({ + "connection.attempt": attempt, + "connection.generation": generation, + "connection.retry.failure_count": Option.match(pendingRetry, { + onNone: () => 0, + onSome: (retry) => retry.failureCount, + }), + }); + const lease = yield* effect.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.some(attemptSpan), + }), + ), + ); + return { attemptSpan: Option.some(attemptSpan), lease }; + }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); + + return Option.match(pendingRetry, { + onNone: () => traced, + onSome: (retry) => + traced.pipe( + Effect.linkSpans(retry.previousAttempt, { + "connection.retry.delay_ms": retry.delayMs, + "connection.retry.reason": retry.reason, + }), + ), + }).pipe(withRelayClientTracing); + }; + + const establishTracedConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + if (target._tag === "RelayConnectionTarget") { + return yield* traceRelayEstablishment( + establishConnection(attempt, generation, lastFailure), + attempt, + generation, + pendingRetry, + ); + } + return yield* establishConnection(attempt, generation, lastFailure).pipe( + Effect.map((lease) => ({ + attemptSpan: Option.none(), + lease, + })), + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.none(), + }), + ), + ); + }); + + const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "ConnectRequested": + break; + case "Wakeup": + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return; + } + break; + } + } + }); + + const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "Wakeup": + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return; + } + if (next.reason === "application-active") { + const probe = yield* lease.session.probe.pipe( + Effect.timeoutOrElse({ + duration: CONNECTION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new ConnectionTransientError({ + reason: "timeout", + message: `${target.label} did not respond to a connection health check.`, + }), + ), + }), + Effect.forkChild, + ); + for (;;) { + const probeEvent = yield* Effect.raceFirst( + Fiber.await(probe).pipe( + Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), + ), + Queue.take(signals).pipe( + Effect.map((signal) => ({ _tag: "Signal" as const, signal })), + ), + ); + if (probeEvent._tag === "ProbeCompleted") { + yield* probeEvent.exit; + break; + } + switch (probeEvent.signal._tag) { + case "DisconnectRequested": + case "RetryRequested": + yield* Fiber.interrupt(probe); + return; + case "NetworkChanged": + if (probeEvent.signal.network === "offline") { + yield* Fiber.interrupt(probe); + return; + } + break; + case "ConnectRequested": + case "Wakeup": + break; + } + } + } + break; + case "ConnectRequested": + break; + } + } + }); + + const runAttempt = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + yield* SubscriptionRef.set(prepared, Option.none()); + const establishment = yield* Effect.raceAllFirst([ + exitUnlessInterrupted( + establishTracedConnection(attempt, generation, lastFailure, pendingRetry), + ).pipe( + Effect.map( + (exit): EstablishmentEvent => ({ + _tag: "Completed", + exit, + }), + ), + ), + waitForEstablishmentInterrupt().pipe(Effect.as({ _tag: "Interrupted" })), + Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( + Effect.as({ _tag: "TimedOut" }), + ), + ]); + + if (establishment._tag === "Interrupted") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + if (establishment._tag === "TimedOut") { + return { + _tag: "Failure", + established: false, + stable: false, + failure: { + error: new ConnectionTransientError({ + reason: "timeout", + message: `${target.label} did not respond during connection setup.`, + }), + attemptSpan: Option.none(), + }, + } satisfies AttemptOutcome; + } + if (Exit.isFailure(establishment.exit)) { + const isUnexpectedDefect = + !Cause.hasInterruptsOnly(establishment.exit.cause) && + !establishment.exit.cause.reasons.some(Cause.isFailReason); + const outcome = failureFromExit(target, establishment.exit, false, false); + if (isUnexpectedDefect) { + yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + cause: Cause.pretty(establishment.exit.cause), + }), + ); + } + return outcome; + } + + const active = establishment.exit.value; + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired || currentIntent.network === "offline") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + + const connectedAt = yield* Clock.currentTimeMillis; + yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); + yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + yield* setState({ + desired: true, + network: currentIntent.network, + phase: "connected", + stage: null, + attempt, + generation, + lastFailure: null, + retryAt: null, + }); + + const connectedExit = yield* Effect.raceFirst( + active.lease.session.closed.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + monitorConnectedLease(active.lease).pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + ).pipe(exitUnlessInterrupted); + const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; + return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); + }, Effect.ensuring(clearLease)); + + const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { + return yield* Effect.raceFirst( + Effect.sleep(delayMs), + Effect.gen(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "ConnectRequested": + case "DisconnectRequested": + case "RetryRequested": + case "NetworkChanged": + case "Wakeup": + return; + } + } + }), + ); + }); + + const waitForSignal = Queue.take(signals); + + const run = Effect.fnUntraced(function* () { + let failureCount = 0; + let generation = 0; + let latestFailure: ConnectionAttemptError | null = null; + let pendingRetry = Option.none(); + + for (;;) { + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + yield* clearLease; + yield* setState(availableState(currentIntent, generation)); + yield* waitForSignal; + continue; + } + if (currentIntent.network === "offline") { + yield* clearLease; + yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); + yield* waitForSignal; + continue; + } + + const attempt = failureCount + 1; + const nextGeneration = generation + 1; + const outcome: AttemptOutcome = yield* Effect.scoped( + runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + ); + if (outcome.established) { + generation = nextGeneration; + if (outcome.stable) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + } + } + if (outcome._tag === "Interrupted") { + continue; + } + + const attemptSpan: Option.Option = outcome.failure.attemptSpan; + const error: ConnectionAttemptError = outcome.failure.error; + latestFailure = error; + if (error._tag === "ConnectionBlockedError") { + const blockedIntent = yield* Ref.get(intent); + yield* setState({ + desired: blockedIntent.desired, + network: blockedIntent.network, + phase: "blocked", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: null, + }); + yield* waitForSignal; + continue; + } + + failureCount += 1; + const delayMs = retryDelayMs(failureCount - 1); + pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ + previousAttempt, + failureCount, + delayMs, + reason: error.reason, + })); + const failedIntent = yield* Ref.get(intent); + yield* setState({ + desired: failedIntent.desired, + network: failedIntent.network, + phase: "backoff", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: (yield* Clock.currentTimeMillis) + delayMs, + }); + yield* waitForRetrySignal(delayMs); + } + }); + + yield* connectivity.changes.pipe( + Stream.runForEach((network) => + Ref.modify(intent, (current) => + current.network === network ? [false, current] : ([true, { ...current, network }] as const), + ).pipe( + Effect.flatMap((changed) => + changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* wakeups.changes.pipe( + Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), + Effect.forkScoped, + ); + yield* run().pipe(Effect.forkScoped); + + const connect = Ref.update(intent, (current) => ({ + ...current, + desired: true, + })).pipe( + Effect.andThen(signal({ _tag: "ConnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.connect"), + ); + + const disconnect = Ref.update(intent, (current) => ({ + ...current, + desired: false, + })).pipe( + Effect.andThen(signal({ _tag: "DisconnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.disconnect"), + ); + + const retryNow = signal({ _tag: "RetryRequested" }).pipe( + Effect.withSpan("EnvironmentSupervisor.retryNow"), + ); + + yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); + + return EnvironmentSupervisor.of({ + target, + state, + session, + prepared, + connect, + disconnect, + retryNow, + }); +}); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts new file mode 100644 index 00000000000..93449077838 --- /dev/null +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -0,0 +1,11 @@ +import * as Context from "effect/Context"; +import type * as Stream from "effect/Stream"; + +export type ConnectionWakeup = "application-active" | "credentials-changed"; + +export class ConnectionWakeups extends Context.Service< + ConnectionWakeups, + { + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} diff --git a/packages/client-runtime/src/environment/descriptor.ts b/packages/client-runtime/src/environment/descriptor.ts new file mode 100644 index 00000000000..d49a0d9a890 --- /dev/null +++ b/packages/client-runtime/src/environment/descriptor.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; + +import { environmentEndpointUrl } from "./endpoint.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +export const fetchRemoteEnvironmentDescriptor = Effect.fn( + "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", +)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.metadata.descriptor(), + ); +}); diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts similarity index 98% rename from packages/client-runtime/src/advertisedEndpoint.test.ts rename to packages/client-runtime/src/environment/endpoint.test.ts index b55c6d817dd..d26201dc4f7 100644 --- a/packages/client-runtime/src/advertisedEndpoint.test.ts +++ b/packages/client-runtime/src/environment/endpoint.test.ts @@ -5,7 +5,7 @@ import { createAdvertisedEndpoint, deriveWsBaseUrl, normalizeHttpBaseUrl, -} from "./advertisedEndpoint.ts"; +} from "./endpoint.ts"; const coreProvider = { id: "desktop-core", diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts new file mode 100644 index 00000000000..4178259361e --- /dev/null +++ b/packages/client-runtime/src/environment/endpoint.ts @@ -0,0 +1,9 @@ +export * from "@t3tools/shared/advertisedEndpoint"; + +export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = pathname; + url.search = ""; + url.hash = ""; + return url.toString(); +}; diff --git a/packages/client-runtime/src/environment/index.ts b/packages/client-runtime/src/environment/index.ts new file mode 100644 index 00000000000..03c6bf6e491 --- /dev/null +++ b/packages/client-runtime/src/environment/index.ts @@ -0,0 +1,4 @@ +export * from "./descriptor.ts"; +export * from "./endpoint.ts"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts similarity index 72% rename from packages/client-runtime/src/knownEnvironment.test.ts rename to packages/client-runtime/src/environment/knownEnvironment.test.ts index cb96ab2417e..66bbb1df7e9 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; +import { createKnownEnvironment } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -32,32 +32,6 @@ describe("known environment bootstrap helpers", () => { }, }); }); - - it("returns the explicit fetchable http origin", () => { - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Local environment", - target: { - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - }, - }), - ), - ).toBe("http://localhost:3773"); - - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Remote environment", - target: { - httpBaseUrl: "https://remote.example.com/api", - wsBaseUrl: "wss://remote.example.com/api", - }, - }), - ), - ).toBe("https://remote.example.com/api"); - }); }); describe("scoped refs", () => { diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/environment/knownEnvironment.ts similarity index 77% rename from packages/client-runtime/src/knownEnvironment.ts rename to packages/client-runtime/src/environment/knownEnvironment.ts index 495a6ddc9a7..42d3c8fbeb1 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.ts @@ -29,18 +29,6 @@ export function createKnownEnvironment(input: { }; } -export function getKnownEnvironmentWsBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.wsBaseUrl ?? null; -} - -export function getKnownEnvironmentHttpBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.httpBaseUrl ?? null; -} - export function attachEnvironmentDescriptor( environment: KnownEnvironment, descriptor: ExecutionEnvironmentDescriptor, diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/environment/scoped.ts similarity index 100% rename from packages/client-runtime/src/scoped.ts rename to packages/client-runtime/src/environment/scoped.ts diff --git a/packages/client-runtime/src/environmentConnection.ts b/packages/client-runtime/src/environmentConnection.ts deleted file mode 100644 index 636b1808595..00000000000 --- a/packages/client-runtime/src/environmentConnection.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - ServerConfig, - ServerLifecycleWelcomePayload, - TerminalEvent, -} from "@t3tools/contracts"; - -import type { KnownEnvironment } from "./knownEnvironment.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface EnvironmentConnection { - readonly kind: "primary" | "saved"; - readonly environmentId: EnvironmentId; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly ensureBootstrapped: () => Promise; - readonly reconnect: () => Promise; - readonly dispose: () => Promise; -} - -interface OrchestrationHandlers { - readonly applyShellEvent: ( - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, - ) => void; - readonly syncShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionInput extends OrchestrationHandlers { - readonly kind: "primary" | "saved"; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly refreshMetadata?: () => Promise; - readonly onConfigSnapshot?: (config: ServerConfig) => void; - readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; - readonly onShellResubscribe?: (environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionAttempt { - readonly environmentId: EnvironmentId; - readonly isCurrent: () => boolean; -} - -export class EnvironmentConnectionAttemptCancelledError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection attempt ${environmentId} was cancelled.`); - this.name = "EnvironmentConnectionAttemptCancelledError"; - } -} - -export function createEnvironmentConnectionAttemptRegistry() { - const attempts = new Map(); - - return { - begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => { - const id = Symbol(environmentId); - attempts.set(environmentId, id); - return { - environmentId, - isCurrent: () => attempts.get(environmentId) === id, - }; - }, - cancel: (environmentId: EnvironmentId): void => { - attempts.delete(environmentId); - }, - clear: (): void => { - attempts.clear(); - }, - }; -} - -export class EnvironmentConnectionDisposedError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`); - this.name = "EnvironmentConnectionDisposedError"; - } -} - -function createBootstrapGate() { - let resolve: (() => void) | null = null; - let reject: ((error: unknown) => void) | null = null; - const makePromise = () => { - const nextPromise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - void nextPromise.catch(() => undefined); - return nextPromise; - }; - let promise = makePromise(); - - return { - wait: () => promise, - resolve: () => { - resolve?.(); - resolve = null; - reject = null; - }, - reject: (error: unknown) => { - reject?.(error); - resolve = null; - reject = null; - }, - reset: () => { - promise = makePromise(); - }, - }; -} - -export function createEnvironmentConnection( - input: EnvironmentConnectionInput, -): EnvironmentConnection { - const environmentId = input.knownEnvironment.environmentId; - - if (!environmentId) { - throw new Error( - `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, - ); - } - - let disposed = false; - const bootstrapGate = createBootstrapGate(); - const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; - const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; - - const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { - if (environmentId !== nextEnvironmentId) { - throw new Error( - `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, - ); - } - }; - - const unsubLifecycle = shouldObserveLifecycle - ? input.client.server.subscribeLifecycle((event) => { - if (disposed || event.type !== "welcome") { - return; - } - - observeEnvironmentIdentity( - event.payload.environment.environmentId, - "server lifecycle welcome", - ); - input.onWelcome?.(event.payload); - }) - : () => undefined; - - const unsubConfig = shouldObserveConfig - ? input.client.server.subscribeConfig((event) => { - if (disposed || event.type !== "snapshot") { - return; - } - - observeEnvironmentIdentity( - event.config.environment.environmentId, - "server config snapshot", - ); - input.onConfigSnapshot?.(event.config); - }) - : () => undefined; - - const unsubShell = input.client.orchestration.subscribeShell( - (item) => { - if (disposed) { - return; - } - - if (item.kind === "snapshot") { - input.syncShellSnapshot(item.snapshot, environmentId); - bootstrapGate.resolve(); - return; - } - - input.applyShellEvent(item, environmentId); - }, - { - onResubscribe: () => { - if (disposed) { - return; - } - - bootstrapGate.reset(); - input.onShellResubscribe?.(environmentId); - }, - }, - ); - - const unsubTerminalEvent = input.applyTerminalEvent - ? input.client.terminal.onEvent((event) => { - if (!disposed) { - input.applyTerminalEvent?.(event, environmentId); - } - }) - : () => undefined; - - const cleanup = () => { - if (disposed) { - return; - } - - disposed = true; - bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId)); - unsubShell(); - unsubTerminalEvent(); - unsubLifecycle(); - unsubConfig(); - }; - - return { - kind: input.kind, - environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: () => - disposed - ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId)) - : bootstrapGate.wait(), - reconnect: async () => { - if (disposed) { - throw new EnvironmentConnectionDisposedError(environmentId); - } - - bootstrapGate.reset(); - try { - await input.client.reconnect(); - await input.refreshMetadata?.(); - await bootstrapGate.wait(); - } catch (error) { - bootstrapGate.reject(error); - throw error; - } - }, - dispose: async () => { - cleanup(); - await input.client.dispose(); - }, - }; -} diff --git a/packages/client-runtime/src/environmentRuntimeState.test.ts b/packages/client-runtime/src/environmentRuntimeState.test.ts deleted file mode 100644 index 79b245335a9..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createEnvironmentRuntimeManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("stores state per environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - }); - - it("patches the current state", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.patch(TARGET, (current) => ({ - ...current, - connectionState: "disconnected", - connectionError: "Socket closed.", - })); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "disconnected", - connectionError: "Socket closed.", - serverConfig: null, - }); - }); - - it("invalidates a single environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "idle", - connectionError: null, - serverConfig: null, - }); - }); -}); diff --git a/packages/client-runtime/src/environmentRuntimeState.ts b/packages/client-runtime/src/environmentRuntimeState.ts deleted file mode 100644 index e25979c8cfd..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type EnvironmentConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; - -export interface EnvironmentRuntimeState { - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly serverConfig: T3ServerConfig | null; -} - -export interface EnvironmentRuntimeTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze({ - connectionState: "idle", - connectionError: null, - serverConfig: null, -}); - -const knownEnvironmentRuntimeKeys = new Set(); - -export const environmentRuntimeStateAtom = Atom.family((key: string) => { - knownEnvironmentRuntimeKeys.add(key); - return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`environment-runtime:${key}`), - ); -}); - -export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("environment-runtime:null"), -); - -export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null { - return target.environmentId; -} - -export interface EnvironmentRuntimeManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) { - function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return EMPTY_ENVIRONMENT_RUNTIME_STATE; - } - - return config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - } - - function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState); - } - - function patch( - target: EnvironmentRuntimeTarget, - updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState, - ): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current)); - } - - function invalidate(target?: EnvironmentRuntimeTarget): void { - if (target) { - setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE); - return; - } - - for (const key of knownEnvironmentRuntimeKeys) { - config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - getSnapshot, - setState, - patch, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/errors/errorTrace.test.ts b/packages/client-runtime/src/errors/errorTrace.test.ts new file mode 100644 index 00000000000..075049bd55e --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findErrorTraceId } from "./errorTrace.ts"; + +describe("findErrorTraceId", () => { + it("finds trace metadata through wrapped typed errors", () => { + expect( + findErrorTraceId({ + cause: { + cause: { + _tag: "RelayInternalError", + traceId: "trace-relay", + }, + }, + }), + ).toBe("trace-relay"); + }); + + it("terminates for cyclic causes", () => { + const error: { cause?: unknown } = {}; + error.cause = error; + + expect(findErrorTraceId(error)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/errors/errorTrace.ts b/packages/client-runtime/src/errors/errorTrace.ts new file mode 100644 index 00000000000..ec1b2a6b2cd --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.ts @@ -0,0 +1,18 @@ +export function findErrorTraceId(error: unknown): string | null { + const seen = new Set(); + let current: unknown = error; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const record = current as { + readonly cause?: unknown; + readonly traceId?: unknown; + }; + if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { + return record.traceId; + } + current = record.cause; + } + + return null; +} diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts new file mode 100644 index 00000000000..a29060e6758 --- /dev/null +++ b/packages/client-runtime/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./errorTrace.ts"; +export * from "./transport.ts"; diff --git a/packages/client-runtime/src/transportError.test.ts b/packages/client-runtime/src/errors/transport.test.ts similarity index 80% rename from packages/client-runtime/src/transportError.test.ts rename to packages/client-runtime/src/errors/transport.test.ts index 7c0417a91ef..692b3af4a51 100644 --- a/packages/client-runtime/src/transportError.test.ts +++ b/packages/client-runtime/src/errors/transport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts"; +import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts"; describe("isTransportConnectionErrorMessage", () => { it("returns true for SocketCloseError", () => { @@ -19,6 +19,17 @@ describe("isTransportConnectionErrorMessage", () => { ).toBe(true); }); + it("recognizes connection errors emitted by the Effect RPC session", () => { + expect(isTransportConnectionErrorMessage("Test environment disconnected.")).toBe(true); + expect( + isTransportConnectionErrorMessage( + "Test environment could not establish a WebSocket connection.", + ), + ).toBe(true); + expect(isTransportConnectionErrorMessage("Test environment is not connected.")).toBe(true); + expect(isTransportConnectionErrorMessage("ClientProtocolError: socket closed")).toBe(true); + }); + it("returns true for the T3 server WebSocket message", () => { expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( true, diff --git a/packages/client-runtime/src/transportError.ts b/packages/client-runtime/src/errors/transport.ts similarity index 81% rename from packages/client-runtime/src/transportError.ts rename to packages/client-runtime/src/errors/transport.ts index fe0ad9f98d6..e21c5d4ecf5 100644 --- a/packages/client-runtime/src/transportError.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -3,11 +3,16 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bSocketOpenError\b/i, /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, + /\bis not connected\.$/i, + /\bdisconnected\.$/i, + /\bcould not establish a WebSocket connection\.$/i, + /\bClientProtocolError\b/i, + /\bRpcClientError\b/i, /\bping timeout\b/i, ] as const; /** - * Test whether an error message originates from a transport-level connection + * Check whether an error message originates from a transport-level connection * failure (socket close, socket open, ping timeout, etc.) rather than a * business-logic error. */ diff --git a/packages/client-runtime/src/filesystemBrowseState.test.ts b/packages/client-runtime/src/filesystemBrowseState.test.ts deleted file mode 100644 index c06ac6806ae..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { FilesystemBrowseResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_FILESYSTEM_BROWSE_STATE, - createFilesystemBrowseManager, -} from "./filesystemBrowseState.ts"; - -const ROOT_RESULT: FilesystemBrowseResult = { - parentPath: "/Users/julius", - entries: [ - { - name: "code", - fullPath: "/Users/julius/code", - }, - ], -}; - -let registry = AtomRegistry.make(); - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function unresolvedBrowse() { - throw new Error("Browse resolver was not initialized."); -} - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores browsed folder data in an atom snapshot", async () => { - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => ROOT_RESULT, - }), - }); - - assert.deepStrictEqual( - manager.getSnapshot({ key: null, input: null }), - EMPTY_FILESYSTEM_BROWSE_STATE, - ); - - const target = { key: "env-1", input: { partialPath: "~" } }; - const result = await manager.refresh(target); - - assert.strictEqual(result, ROOT_RESULT); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight browse refreshes by target input", async () => { - let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse; - let calls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: () => { - calls += 1; - return new Promise((resolve) => { - resolveBrowse = resolve; - }); - }, - }), - }); - - const first = manager.refresh(target); - const second = manager.refresh(target); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: null, - error: null, - isPending: true, - }); - - resolveBrowse(ROOT_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps fresh watched browse results on remount", async () => { - let browseCalls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => { - browseCalls += 1; - return ROOT_RESULT; - }, - }), - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch(target); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch(target); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(browseCalls, 1); -}); diff --git a/packages/client-runtime/src/filesystemBrowseState.ts b/packages/client-runtime/src/filesystemBrowseState.ts deleted file mode 100644 index b1c72966d4d..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface FilesystemBrowseState { - readonly data: FilesystemBrowseResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface FilesystemBrowseTarget { - readonly key: TKey | null; - readonly input: FilesystemBrowseInput | null; -} - -export interface FilesystemBrowseClient { - readonly browse: (input: FilesystemBrowseInput) => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownFilesystemBrowseKeys = new Set(); - -export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => { - knownFilesystemBrowseKeys.add(targetKey); - return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`filesystem-browse:${targetKey}`), - ); -}); - -export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("filesystem-browse:null"), -); - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function getFilesystemBrowseTargetKey( - target: FilesystemBrowseTarget, -): string | null { - const key = target.key; - const input = target.input; - if (!key || !input || input.partialPath.length === 0) { - return null; - } - - return JSON.stringify([key, input.cwd ?? null, input.partialPath]); -} - -export interface FilesystemBrowseManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (key: TKey) => FilesystemBrowseClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -export function createFilesystemBrowseManager( - config: FilesystemBrowseManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: FilesystemBrowseClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - function setState(targetKey: string, nextState: FilesystemBrowseState): void { - config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - const next: FilesystemBrowseState = - current.data === null - ? INITIAL_FILESYSTEM_BROWSE_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: FilesystemBrowseResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to browse folder.", - isPending: false, - }); - } - - function refresh( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): Promise { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null || target.input === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(target.key); - if (!resolvedClient) { - setError(targetKey, new Error("Filesystem browser client is unavailable.")); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.browse(target.input).then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - function invalidate(target?: FilesystemBrowseTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - - function getSnapshot(target: FilesystemBrowseTarget): FilesystemBrowseState { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return EMPTY_FILESYSTEM_BROWSE_STATE; - } - - return config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - } - - function watch( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): () => void { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: FilesystemBrowseClient | null = null; - - const sync = () => { - const resolved = config.getClient(target.key!); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(target.key)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): void { - refreshTargets.set(targetKey, target); - const registry = config.getRegistry(); - void registry.get(watchedRefreshAtom(targetKey)); - if (client) { - void refresh(target, client); - } - } - - function reset(): void { - refreshInFlight.clear(); - watched.clear(); - refreshTargets.clear(); - for (const targetKey of knownFilesystemBrowseKeys) { - bumpRefreshVersion(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - } - - return { - refresh, - invalidate, - getSnapshot, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/gitActions.test.ts b/packages/client-runtime/src/gitActions.test.ts deleted file mode 100644 index 39c6718347a..00000000000 --- a/packages/client-runtime/src/gitActions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { assert, describe, it } from "vite-plus/test"; - -import { resolveLiveThreadBranchUpdate } from "./gitActions.js"; - -function status(refName: string): VcsStatusResult { - return { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: refName === "main", - refName, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; -} - -describe("resolveLiveThreadBranchUpdate", () => { - it("allows a temporary worktree ref to reconcile to a semantic branch", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "t3code/a9628676", - gitStatus: status("feature/diff-panel-toggle"), - }); - - assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); - }); - - it("still reconciles ordinary semantic branch changes", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old", - gitStatus: status("feature/new"), - }); - - assert.deepEqual(update, { branch: "feature/new" }); - }); -}); diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts deleted file mode 100644 index ac32e794fe4..00000000000 --- a/packages/client-runtime/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./advertisedEndpoint.ts"; -export * from "./knownEnvironment.ts"; -export * from "./reconnectBackoff.ts"; -export * from "./scoped.ts"; -export * from "./projectPaths.ts"; -export * from "./addProject.ts"; -export * from "./filesystemBrowseState.ts"; -export * from "./sourceControlDiscoveryState.ts"; -export * from "./environmentRuntimeState.ts"; -export * from "./shellTypes.ts"; -export * from "./shellSnapshotReducer.ts"; -export * from "./shellSnapshotState.ts"; -export * from "./threadDetailReducer.ts"; -export * from "./threadDetailState.ts"; -export * from "./gitActions.ts"; -export * from "./vcsActionState.ts"; -export * from "./vcsRefState.ts"; -export * from "./vcsStatusState.ts"; -export * from "./terminalSessionState.ts"; -export * from "./transportError.ts"; -export * from "./wsRpcProtocol.ts"; -export * from "./wsTransport.ts"; -export * from "./wsRpcClient.ts"; -export * from "./environmentConnection.ts"; -export * from "./composerPathSearchState.ts"; -export * from "./archivedThreadsState.ts"; -export * from "./checkpointDiffState.ts"; -export * from "./remote.ts"; -export * from "./managedRelay.ts"; -export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts deleted file mode 100644 index e340f12f620..00000000000 --- a/packages/client-runtime/src/managedRelay.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; -import { describe, expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; - -import { - MANAGED_RELAY_REQUEST_TIMEOUT_MS, - ManagedRelayClient, - ManagedRelayDpopSigner, - managedRelayClientLayer, - type ManagedRelayDpopProofInput, -} from "./managedRelay.ts"; -import { remoteHttpClientLayer } from "./remote.ts"; - -function managedRelayTestLayer( - fetchFn: typeof globalThis.fetch, - relayUrl = "https://relay.example.test", -) { - const httpClientLayer = remoteHttpClientLayer(fetchFn); - const signerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("client-thumbprint"), - createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), - }), - ); - return managedRelayClientLayer({ - relayUrl, - clientId: "t3-mobile", - }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); -} - -describe("ManagedRelayClient", () => { - it.effect("rejects unsafe relay URLs before sending credentials", () => { - let requestCount = 0; - const fetchFn = (() => { - requestCount += 1; - return Promise.resolve(Response.json({})); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const error = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay URL must be a secure absolute HTTPS origin.", - }); - expect(requestCount).toBe(0); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); - }); - - it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { - let tokenExchangeCount = 0; - const fetchFn = ((input) => { - const url = String(input); - if (url.endsWith("/v1/client/dpop-token")) { - tokenExchangeCount += 1; - return Promise.resolve( - Response.json({ - access_token: `relay-token-${tokenExchangeCount}`, - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 10, - scope: RelayEnvironmentStatusScope, - }), - ); - } - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const statusInput = { - clerkToken: "clerk-token", - scopes: [RelayEnvironmentStatusScope], - environmentId: EnvironmentId.make("env-1"), - } as const; - - yield* relayClient.getEnvironmentStatus(statusInput); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(1); - - yield* TestClock.adjust(Duration.seconds(6)); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(2); - - yield* relayClient.resetTokenCache; - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(3); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); - - it.effect("times out stalled relay environment listing requests", () => { - const fetchFn = (() => - new Promise(() => undefined)) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const errorFiber = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip, Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); - const error = yield* Fiber.join(errorFiber); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay environment listing timed out.", - }); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); - }); - - it.effect("lists account devices through the Clerk bearer client endpoint", () => { - const fetchFn = ((input, init) => { - expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); - expect(init?.headers).toMatchObject({ - authorization: "Bearer clerk-token", - }); - return Promise.resolve( - Response.json({ - devices: [ - { - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: "1.0.0", - notifications: { - enabled: false, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", - }, - ], - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); - expect(devices).toMatchObject([ - { - deviceId: "device-1", - label: "Julius's iPhone", - notifications: { - enabled: false, - }, - }, - ]); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); -}); diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts deleted file mode 100644 index f4b9b1f9353..00000000000 --- a/packages/client-runtime/src/managedRelay.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - RelayAccessTokenType, - RelayApi, - type RelayClientEnvironmentRecord, - type RelayClientDeviceRecord, - RelayConnectEnvironmentEndpoint, - type RelayDeviceRegistrationRequest, - type RelayDpopAccessTokenScope, - RelayDpopTokenExchangeGrantType, - type RelayEnvironmentConnectRequest, - type RelayEnvironmentConnectResponse, - type RelayEnvironmentLinkChallengeRequest, - type RelayEnvironmentLinkChallengeResponse, - type RelayEnvironmentLinkRequest, - type RelayEnvironmentLinkResponse, - type RelayEnvironmentStatusResponse, - RelayExchangeDpopAccessTokenEndpoint, - RelayGetEnvironmentStatusEndpoint, - RelayJwtSubjectTokenType, - type RelayLiveActivityRegistrationRequest, - RelayMobileRegistrationScope, - type RelayOkResponse, - type RelayPublicClientId, - RelayRegisterDeviceEndpoint, - RelayRegisterLiveActivityEndpoint, - RelayUnregisterDeviceEndpoint, -} from "@t3tools/contracts/relay"; -import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import type { HttpMethod } from "effect/unstable/http/HttpMethod"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -export interface ManagedRelayDpopProofInput { - readonly method: HttpMethod; - readonly url: string; - readonly accessToken?: string; -} - -export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ - readonly cause: unknown; -}> {} - -export interface ManagedRelayDpopSignerShape { - readonly thumbprint: Effect.Effect; - readonly createProof: ( - input: ManagedRelayDpopProofInput, - ) => Effect.Effect; -} - -export class ManagedRelayDpopSigner extends Context.Service< - ManagedRelayDpopSigner, - ManagedRelayDpopSignerShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} - -export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; - -interface CachedRelayAccessToken { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly accessToken: string; - readonly expiresAtMillis: number; -} - -export interface ManagedRelayAuthorization { - readonly accessToken: string; - readonly proof: string; - readonly thumbprint: string; -} - -export interface ManagedRelayClientLayerOptions { - readonly relayUrl: string; - readonly clientId: RelayPublicClientId; -} - -export interface ManagedRelayClientShape { - readonly relayUrl: string; - readonly listEnvironments: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly listDevices: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly createEnvironmentLinkChallenge: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkChallengeRequest; - }) => Effect.Effect; - readonly linkEnvironment: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkRequest; - }) => Effect.Effect; - readonly unlinkEnvironment: (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly getEnvironmentStatus: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly connectEnvironment: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly deviceId?: string; - }) => Effect.Effect; - readonly registerDevice: (input: { - readonly clerkToken: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregisterDevice: (input: { - readonly clerkToken: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly registerLiveActivity: (input: { - readonly clerkToken: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly resetTokenCache: Effect.Effect; -} - -export class ManagedRelayClient extends Context.Service< - ManagedRelayClient, - ManagedRelayClientShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} - -function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { - return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); -} - -function timeoutRelayRequest(message: string) { - return ( - request: Effect.Effect, - ): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(relayClientError(message)), - onSome: Effect.succeed, - }), - ), - ); -} - -function tokenMatches( - token: CachedRelayAccessToken, - input: { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly nowMillis: number; - }, -): boolean { - return ( - token.clerkToken === input.clerkToken && - token.thumbprint === input.thumbprint && - token.expiresAtMillis > input.nowMillis + 5_000 && - input.scopes.every((scope) => token.scopes.includes(scope)) - ); -} - -function bearerHeaders(clerkToken: string) { - return { authorization: `Bearer ${clerkToken}` }; -} - -function dpopHeaders(authorization: ManagedRelayAuthorization) { - return { - authorization: `DPoP ${authorization.accessToken}`, - dpop: authorization.proof, - }; -} - -function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { - const unavailable = () => - Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: unavailable, - listDevices: unavailable, - createEnvironmentLinkChallenge: unavailable, - linkEnvironment: unavailable, - unlinkEnvironment: unavailable, - getEnvironmentStatus: unavailable, - connectEnvironment: unavailable, - registerDevice: unavailable, - unregisterDevice: unavailable, - registerLiveActivity: unavailable, - resetTokenCache: Effect.void, - }); -} - -export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { - return Layer.effect( - ManagedRelayClient, - Effect.gen(function* () { - const relayUrl = normalizeSecureRelayUrl(options.relayUrl); - if (relayUrl === null) { - return disabledManagedRelayClient(options.relayUrl); - } - const signer = yield* ManagedRelayDpopSigner; - const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); - const cachedTokens = yield* SynchronizedRef.make>([]); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); - - type DpopProofTarget = Pick; - const dpopProofTargets = { - exchangeAccessToken: (): DpopProofTarget => ({ - method: RelayExchangeDpopAccessTokenEndpoint.method, - url: urlBuilder.token.exchangeDpopAccessToken(), - }), - getEnvironmentStatus: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayGetEnvironmentStatusEndpoint.method, - url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), - }), - connectEnvironment: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayConnectEnvironmentEndpoint.method, - url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), - }), - registerDevice: (): DpopProofTarget => ({ - method: RelayRegisterDeviceEndpoint.method, - url: urlBuilder.mobile.registerDevice(), - }), - unregisterDevice: (deviceId: string): DpopProofTarget => ({ - method: RelayUnregisterDeviceEndpoint.method, - url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), - }), - registerLiveActivity: (): DpopProofTarget => ({ - method: RelayRegisterLiveActivityEndpoint.method, - url: urlBuilder.mobile.registerLiveActivity(), - }), - }; - - const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly thumbprint: string; - }) { - const nowMillis = yield* Clock.currentTimeMillis; - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { - const activeTokens = tokens.filter( - (token) => token.expiresAtMillis > nowMillis + 5_000, - ); - const cached = activeTokens.find((token) => - tokenMatches(token, { ...input, nowMillis }), - ); - if (cached) { - return Effect.succeed([cached, activeTokens] as const); - } - return Effect.gen(function* () { - const proof = yield* signer - .createProof(dpopProofTargets.exchangeAccessToken()) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay token DPoP proof.", cause), - ), - ); - const response = yield* client.token - .exchangeDpopAccessToken({ - headers: { dpop: proof }, - payload: { - grant_type: RelayDpopTokenExchangeGrantType, - subject_token: input.clerkToken, - subject_token_type: RelayJwtSubjectTokenType, - requested_token_type: RelayAccessTokenType, - resource: relayUrl, - scope: encodeOAuthScope(input.scopes), - client_id: options.clientId, - }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not exchange relay DPoP access token.", cause), - ), - timeoutRelayRequest("Relay DPoP access token exchange timed out."), - ); - if (!oauthScopeSetEquals(response.scope, input.scopes)) { - return yield* relayClientError( - "Relay granted unexpected DPoP access token scopes.", - ); - } - const next: CachedRelayAccessToken = { - clerkToken: input.clerkToken, - thumbprint: input.thumbprint, - scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - }; - return [next, [...activeTokens, next]] as const; - }); - }); - }, - ); - - const authorize = (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }) => - Effect.gen(function* () { - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError((cause) => - relayClientError("Could not load relay DPoP proof key.", cause), - ), - ); - const token = yield* obtainAccessToken({ - clerkToken: input.clerkToken, - scopes: input.scopes, - thumbprint, - }); - const proof = yield* signer - .createProof({ - ...input.target, - accessToken: token.accessToken, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay request DPoP proof.", cause), - ), - ); - return { accessToken: token.accessToken, proof, thumbprint }; - }); - - const authorizeMobileRegistration = (input: { - readonly clerkToken: string; - readonly target: DpopProofTarget; - }) => - authorize({ - ...input, - scopes: [RelayMobileRegistrationScope], - }); - - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: (input) => - client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( - Effect.map((response) => response.environments), - Effect.mapError((cause) => - relayClientError("Could not list relay-managed environments.", cause), - ), - timeoutRelayRequest("Relay environment listing timed out."), - withRelayClientTracing, - ), - listDevices: (input) => - client.client - .listDevices({ - headers: bearerHeaders(input.clerkToken), - }) - .pipe( - Effect.map((response) => response.devices), - Effect.mapError((cause) => - relayClientError("Could not list relay client devices.", cause), - ), - timeoutRelayRequest("Relay client device listing timed out."), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: (input) => - client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay environment link challenge.", cause), - ), - timeoutRelayRequest("Relay environment link challenge timed out."), - withRelayClientTracing, - ), - linkEnvironment: (input) => - client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not link relay environment.", cause), - ), - timeoutRelayRequest("Relay environment linking timed out."), - withRelayClientTracing, - ), - unlinkEnvironment: (input) => - client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unlink relay environment.", cause), - ), - timeoutRelayRequest("Relay environment unlinking timed out."), - withRelayClientTracing, - ), - getEnvironmentStatus: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }); - return yield* client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not get relay environment status.", cause), - ), - timeoutRelayRequest("Relay environment status request timed out."), - ); - }).pipe(withRelayClientTracing), - connectEnvironment: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }); - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return yield* client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not connect relay environment.", cause), - ), - timeoutRelayRequest("Relay environment connection timed out."), - ); - }).pipe(withRelayClientTracing), - registerDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }); - return yield* client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device registration timed out."), - ); - }).pipe(withRelayClientTracing), - unregisterDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }); - return yield* client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unregister relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device unregistration timed out."), - ); - }).pipe(withRelayClientTracing), - registerLiveActivity: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }); - return yield* client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay live activity.", cause), - ), - timeoutRelayRequest("Relay Live Activity registration timed out."), - ); - }).pipe(withRelayClientTracing), - resetTokenCache: SynchronizedRef.set(cachedTokens, []), - }); - }), - ); -} diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts deleted file mode 100644 index ce58241e796..00000000000 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; -import { - createManagedRelayQueryManager, - createManagedRelaySession, - managedRelaySessionAtom, - readManagedRelaySnapshotState, - setManagedRelaySession, - waitForManagedRelayClerkToken, -} from "./managedRelayState.ts"; - -let registry = AtomRegistry.make(); - -const environment = { - environmentId: EnvironmentId.make("environment-1"), - label: "Main environment", - endpoint: { - httpBaseUrl: "https://environment.example.test", - wsBaseUrl: "wss://environment.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientEnvironmentRecord; - -const device = { - deviceId: "device-1", - label: "Julius iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: null, - notifications: { - enabled: true, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientDeviceRecord; - -function resetRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createManager(overrides?: Partial) { - const client = ManagedRelayClient.of({ - relayUrl: "https://relay.example.test", - listEnvironments: () => Effect.succeed([environment]), - listDevices: () => Effect.succeed([device]), - createEnvironmentLinkChallenge: () => Effect.die("unused"), - linkEnvironment: () => Effect.die("unused"), - unlinkEnvironment: () => Effect.die("unused"), - getEnvironmentStatus: () => - Effect.succeed({ - environmentId: environment.environmentId, - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - }), - connectEnvironment: () => Effect.die("unused"), - registerDevice: () => Effect.die("unused"), - unregisterDevice: () => Effect.die("unused"), - registerLiveActivity: () => Effect.die("unused"), - resetTokenCache: Effect.void, - ...overrides, - }); - const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); - return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); -} - -function setSession() { - setManagedRelaySession( - registry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("clerk-token"), - }), - ); -} - -describe("createManagedRelayQueryManager", () => { - afterEach(resetRegistry); - - it("waits for the current cloud session before reading its token", async () => { - const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); - - setSession(); - - await expect(token).resolves.toBe("clerk-token"); - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); - }); - - it("keeps environment snapshots cached and refreshes them explicitly", async () => { - const listEnvironments = vi.fn(() => Effect.succeed([environment])); - const manager = createManager({ listEnvironments }); - setSession(); - const atom = manager.environmentsAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); - - registry.get(manager.environmentsAtom("account-1")); - expect(listEnvironments).toHaveBeenCalledTimes(1); - - manager.refreshEnvironments(registry, "account-1"); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); - }); - - it("loads device snapshots through the current account session", async () => { - const listDevices = vi.fn(() => Effect.succeed([device])); - const manager = createManager({ listDevices }); - setSession(); - const atom = manager.devicesAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); - }); - }); - - it("rejects status responses for a different environment", async () => { - const mismatchedStatus = { - environmentId: EnvironmentId.make("environment-2"), - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - } satisfies RelayEnvironmentStatusResponse; - const manager = createManager({ - getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), - }); - setSession(); - const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( - "Relay returned status for a different environment.", - ); - }); - }); -}); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts new file mode 100644 index 00000000000..e7e59dd85d4 --- /dev/null +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -0,0 +1,140 @@ +import { + CommandId, + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ThreadId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; + +const TEST_CRYPTO_LAYER = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( + dispatched: ClientOrchestrationCommand[], +) { + const client = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisorService); +}); + +describe("environment commands", () => { + it.effect("adds generated command metadata", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + const result = yield* createProject({ + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(result).toEqual({ sequence: 1 }); + expect(dispatched).toEqual([ + { + type: "project.create", + commandId: "00000000-0000-4000-8000-000000000000", + projectId: "project-1", + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("preserves caller metadata for idempotent queued commands", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* stopThreadSession({ + commandId: CommandId.make("queued-command"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-06-06T00:01:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.session.stop", + commandId: "queued-command", + threadId: "thread-1", + createdAt: "2026-06-06T00:01:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("does not add timestamps to commands without createdAt", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* archiveThread({ + commandId: CommandId.make("archive-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.archive", + commandId: "archive-command", + threadId: "thread-1", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts new file mode 100644 index 00000000000..a0c3cbe771f --- /dev/null +++ b/packages/client-runtime/src/operations/commands.ts @@ -0,0 +1,256 @@ +import { + CommandId, + ORCHESTRATION_WS_METHODS, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { + type EnvironmentRpcFailure, + type EnvironmentRpcSuccess, + type EnvironmentRpcUnavailableError, + request, +} from "../rpc/client.ts"; + +type CommandType = ClientOrchestrationCommand["type"]; +type CommandOf = Extract; +type CommandInput = Omit< + CommandOf, + "type" | "commandId" | "createdAt" +> & { + readonly commandId?: CommandId; +} & ("createdAt" extends keyof CommandOf + ? { + readonly createdAt?: CommandOf["createdAt"]; + } + : {}); + +export type CreateProjectInput = CommandInput<"project.create">; +export type UpdateProjectInput = CommandInput<"project.meta.update">; +export type DeleteProjectInput = CommandInput<"project.delete">; +export type CreateThreadInput = CommandInput<"thread.create">; +export type DeleteThreadInput = CommandInput<"thread.delete">; +export type ArchiveThreadInput = CommandInput<"thread.archive">; +export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; +export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; +export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; +export type StartThreadTurnInput = CommandInput<"thread.turn.start">; +export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; +export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; +export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; +export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; +export type StopThreadSessionInput = CommandInput<"thread.session.stop">; + +type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; +type CommandEffect = Effect.Effect< + EnvironmentRpcSuccess, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError, + Crypto.Crypto | EnvironmentSupervisor +>; + +function commandId(input: { readonly commandId?: CommandId }) { + return Effect.gen(function* () { + if (input.commandId !== undefined) { + return input.commandId; + } + const crypto = yield* Crypto.Crypto; + return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make)); + }); +} + +function timestampedCommandMetadata(input: { + readonly commandId?: CommandId; + readonly createdAt?: string; +}) { + return Effect.all({ + commandId: commandId(input), + createdAt: + input.createdAt === undefined + ? DateTime.now.pipe(Effect.map(DateTime.formatIso)) + : Effect.succeed(input.createdAt), + }); +} + +function dispatch(command: ClientOrchestrationCommand) { + return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); +} + +export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createProject", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "project.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.delete", + commandId: yield* commandId(input), + }); +}); + +export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createThread", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.delete", + commandId: yield* commandId(input), + }); +}); + +export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.archiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.archive", + commandId: yield* commandId(input), + }); +}); + +export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unarchiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unarchive", + commandId: yield* commandId(input), + }); +}); + +export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateThreadMetadata", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.setThreadRuntimeMode", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.runtime-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = + Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.interaction-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.startThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.start", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.interruptThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.interrupt", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.approval.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.user-input.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = + Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.checkpoint.revert", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.stopThreadSession", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.session.stop", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/operations/index.ts b/packages/client-runtime/src/operations/index.ts new file mode 100644 index 00000000000..b7307fbb81f --- /dev/null +++ b/packages/client-runtime/src/operations/index.ts @@ -0,0 +1,2 @@ +export * from "./commands.ts"; +export * from "./projects.ts"; diff --git a/packages/client-runtime/src/addProject.test.ts b/packages/client-runtime/src/operations/projects.test.ts similarity index 96% rename from packages/client-runtime/src/addProject.test.ts rename to packages/client-runtime/src/operations/projects.test.ts index fb665996a98..bf4e2c89392 100644 --- a/packages/client-runtime/src/addProject.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -14,8 +14,8 @@ import { getAddProjectInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, -} from "./addProject.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "./projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { it("resolves initial browse paths from settings", () => { @@ -92,7 +92,7 @@ describe("add project shared logic", () => { it("finds existing projects by normalized path in the target environment", () => { const env = EnvironmentId.make("env"); const other = EnvironmentId.make("other"); - const projects: EnvironmentScopedProjectShell[] = [ + const projects: EnvironmentProject[] = [ { environmentId: other, id: ProjectId.make("same-path-other-env"), diff --git a/packages/client-runtime/src/addProject.ts b/packages/client-runtime/src/operations/projects.ts similarity index 94% rename from packages/client-runtime/src/addProject.ts rename to packages/client-runtime/src/operations/projects.ts index fb4e599317f..ec58418a94f 100644 --- a/packages/client-runtime/src/addProject.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -19,8 +19,8 @@ import { isExplicitRelativeProjectPath, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, -} from "./projectPaths.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "../state/projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, @@ -48,7 +48,7 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", @@ -56,7 +56,7 @@ export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = "azure-devops", ]; -export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", "bitbucket", @@ -190,10 +190,10 @@ export function resolveAddProjectPath(input: { } export function findExistingAddProject(input: { - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId; readonly path: string; -}): EnvironmentScopedProjectShell | null { +}): EnvironmentProject | null { return ( findProjectByPath( input.projects.filter((project) => project.environmentId === input.environmentId), diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts new file mode 100644 index 00000000000..ddc93046b37 --- /dev/null +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -0,0 +1,61 @@ +import { + type AuthClientPresentationMetadata, + type AuthEnvironmentScope, + type DesktopSshEnvironmentBootstrap, + type DesktopSshEnvironmentTarget, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export interface PreparedSshEnvironment { + readonly bootstrap: DesktopSshEnvironmentBootstrap; + readonly bearerToken: string; +} + +export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export class CloudSession extends Context.Service< + CloudSession, + { + readonly clerkToken: Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {} + +export class RelayDeviceIdentity extends Context.Service< + RelayDeviceIdentity, + { + readonly deviceId: Effect.Effect, ConnectionAttemptError>; + } +>()("@t3tools/client-runtime/platform/capabilities/RelayDeviceIdentity") {} + +export class ClientPresentation extends Context.Service< + ClientPresentation, + { + readonly metadata: AuthClientPresentationMetadata; + readonly scopes: ReadonlyArray; + } +>()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} + +export class SshEnvironmentGateway extends Context.Service< + SshEnvironmentGateway, + { + readonly provision: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + readonly prepare: (input: { + readonly connectionId: string; + readonly expectedEnvironmentId: EnvironmentId; + readonly target: DesktopSshEnvironmentTarget; + }) => Effect.Effect; + readonly disconnect: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts new file mode 100644 index 00000000000..0c937549771 --- /dev/null +++ b/packages/client-runtime/src/platform/index.ts @@ -0,0 +1,4 @@ +export * from "./capabilities.ts"; +export * from "./persistence.ts"; +export * from "./source.ts"; +export * from "./storageDocument.ts"; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts new file mode 100644 index 00000000000..71664bf4601 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.ts @@ -0,0 +1,84 @@ +import { + type EnvironmentId, + type OrchestrationThread, + type OrchestrationShellSnapshot, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { ConnectionTarget } from "../connection/model.ts"; + +export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( + "ConnectionPersistenceError", + { + operation: Schema.Literals([ + "list-targets", + "register-connection", + "remove-connection", + "load-shell", + "save-shell", + "load-thread", + "save-thread", + "remove-thread", + "clear-environment", + ]), + message: Schema.String, + }, +) {} + +export class ConnectionTargetStore extends Context.Service< + ConnectionTargetStore, + { + readonly list: Effect.Effect, ConnectionPersistenceError>; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} + +export class ConnectionRegistrationStore extends Context.Service< + ConnectionRegistrationStore, + { + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly remove: (target: ConnectionTarget) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} + +export class EnvironmentCacheStore extends Context.Service< + EnvironmentCacheStore, + { + readonly loadShell: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveShell: ( + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, + ) => Effect.Effect; + readonly loadThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveThread: ( + environmentId: EnvironmentId, + thread: OrchestrationThread, + ) => Effect.Effect; + readonly removeThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect; + readonly clear: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} + +export class EnvironmentOwnedDataCleanup extends Context.Reference<{ + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; +}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { + defaultValue: () => ({ + clear: () => Effect.void, + }), +}) {} diff --git a/packages/client-runtime/src/platform/source.ts b/packages/client-runtime/src/platform/source.ts new file mode 100644 index 00000000000..8b5bbeeea5f --- /dev/null +++ b/packages/client-runtime/src/platform/source.ts @@ -0,0 +1,11 @@ +import * as Context from "effect/Context"; +import type * as Stream from "effect/Stream"; + +import type { PrimaryConnectionRegistration } from "../connection/catalog.ts"; + +export class PlatformConnectionSource extends Context.Service< + PlatformConnectionSource, + { + readonly registrations: Stream.Stream; + } +>()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts new file mode 100644 index 00000000000..359594033f5 --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -0,0 +1,146 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + SshConnectionRegistration, +} from "../connection/catalog.ts"; +import { + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "../connection/model.ts"; +import { + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, + removeConnectionFromCatalog, +} from "./storageDocument.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + connectionId: "bearer-1", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: ENVIRONMENT_ID, + label: BEARER_TARGET.label, + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); +const REMOTE_TOKEN = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + endpoint: { + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "dpop-token", + expiresAtEpochMs: 1_000_000, + dpopThumbprint: "thumbprint", +}); + +describe("ConnectionCatalogDocument", () => { + it("registers a bearer connection as one catalog mutation", () => { + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(document.targets).toEqual([BEARER_TARGET]); + expect(document.profiles).toEqual([BEARER_PROFILE]); + expect(document.credentials).toEqual([ + { + connectionId: BEARER_TARGET.connectionId, + credential: BEARER_CREDENTIAL, + }, + ]); + }); + + it("replaces obsolete connection metadata without discarding a reusable DPoP token", () => { + const bearer = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + const relayTarget = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + }); + const relay = registerConnectionInCatalog( + bearer, + new RelayConnectionRegistration({ target: relayTarget }), + ); + + expect(relay.targets).toEqual([relayTarget]); + expect(relay.profiles).toEqual([]); + expect(relay.credentials).toEqual([]); + expect(relay.remoteDpopTokens).toEqual([REMOTE_TOKEN]); + }); + + it("removes every catalog record owned by an explicit disconnect", () => { + const registered = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + ); + }); + + it("persists the normalized SSH profile beside its target", () => { + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new SshConnectionRegistration({ target, profile }), + ); + + expect(document.targets).toEqual([target]); + expect(document.profiles).toEqual([profile]); + expect(document.credentials).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts new file mode 100644 index 00000000000..4eafb298e5e --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -0,0 +1,141 @@ +import * as Schema from "effect/Schema"; + +import { + type ConnectionRegistration, + ConnectionCredential, + ConnectionProfile, +} from "../connection/catalog.ts"; +import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; +import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; + +export const StoredConnectionCredential = Schema.Struct({ + connectionId: Schema.String, + credential: ConnectionCredential, +}); +export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; + +export const ConnectionCatalogDocument = Schema.Struct({ + schemaVersion: Schema.Literal(1), + targets: Schema.Array(PersistedConnectionTarget), + profiles: Schema.Array(ConnectionProfile), + credentials: Schema.Array(StoredConnectionCredential), + remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), +}); +export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; + +export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], +}); + +export function replaceCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + next: A, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function removeCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + removedKey: string, +): ReadonlyArray { + return values.filter((value) => key(value) !== removedKey); +} + +function connectionIdOf(target: ConnectionTarget): string | null { + switch (target._tag) { + case "PrimaryConnectionTarget": + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + case "SshConnectionTarget": + return target.connectionId; + } +} + +function removeConnectionMetadata( + document: ConnectionCatalogDocument, + target: ConnectionTarget, + removeRemoteToken: boolean, +): ConnectionCatalogDocument { + const connectionId = connectionIdOf(target); + return { + ...document, + targets: removeCatalogValue( + document.targets, + (value) => value.environmentId, + target.environmentId, + ), + profiles: + connectionId === null + ? document.profiles + : removeCatalogValue(document.profiles, (value) => value.connectionId, connectionId), + credentials: + connectionId === null + ? document.credentials + : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId), + remoteDpopTokens: removeRemoteToken + ? removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + target.environmentId, + ) + : document.remoteDpopTokens, + }; +} + +export function registerConnectionInCatalog( + document: ConnectionCatalogDocument, + registration: ConnectionRegistration, +): ConnectionCatalogDocument { + const target = registration.target; + const previous = document.targets.find( + (candidate) => candidate.environmentId === target.environmentId, + ); + const cleaned = + previous === undefined ? document : removeConnectionMetadata(document, previous, false); + const next: ConnectionCatalogDocument = { + ...cleaned, + targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), + }; + + switch (registration._tag) { + case "RelayConnectionRegistration": + return next; + case "BearerConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, { + connectionId: registration.target.connectionId, + credential: registration.credential, + }), + }; + case "SshConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + }; + } +} + +export function removeConnectionFromCatalog( + document: ConnectionCatalogDocument, + target: ConnectionTarget, +): ConnectionCatalogDocument { + return removeConnectionMetadata(document, target, true); +} diff --git a/packages/client-runtime/src/reconnectBackoff.test.ts b/packages/client-runtime/src/reconnectBackoff.test.ts deleted file mode 100644 index fb6bb415217..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -describe("getReconnectDelayMs", () => { - it("returns exponential delays with default config", () => { - expect(getReconnectDelayMs(0)).toBe(1_000); - expect(getReconnectDelayMs(1)).toBe(2_000); - expect(getReconnectDelayMs(2)).toBe(4_000); - expect(getReconnectDelayMs(3)).toBe(8_000); - expect(getReconnectDelayMs(4)).toBe(16_000); - expect(getReconnectDelayMs(5)).toBe(32_000); - expect(getReconnectDelayMs(6)).toBe(64_000); - }); - - it("returns null when retry index exceeds maxRetries", () => { - expect(getReconnectDelayMs(7)).toBeNull(); - expect(getReconnectDelayMs(100)).toBeNull(); - }); - - it("returns null for negative indices", () => { - expect(getReconnectDelayMs(-1)).toBeNull(); - }); - - it("returns null for non-integer indices", () => { - expect(getReconnectDelayMs(1.5)).toBeNull(); - }); - - it("caps delay at maxDelayMs", () => { - const config: ReconnectBackoffConfig = { - initialDelayMs: 10_000, - backoffFactor: 10, - maxDelayMs: 30_000, - maxRetries: 5, - }; - - expect(getReconnectDelayMs(0, config)).toBe(10_000); - expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000 - expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000 - }); - - it("supports unlimited retries when maxRetries is null", () => { - const config: ReconnectBackoffConfig = { - ...DEFAULT_RECONNECT_BACKOFF, - maxRetries: null, - }; - - expect(getReconnectDelayMs(0, config)).toBe(1_000); - expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs - expect(getReconnectDelayMs(100, config)).toBe(64_000); - }); -}); - -describe("DEFAULT_RECONNECT_BACKOFF", () => { - it("has sensible defaults", () => { - expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000); - expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2); - expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000); - expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7); - }); -}); diff --git a/packages/client-runtime/src/reconnectBackoff.ts b/packages/client-runtime/src/reconnectBackoff.ts deleted file mode 100644 index 4f7ddd15a52..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Configuration for exponential reconnect backoff. - */ -export interface ReconnectBackoffConfig { - /** Base delay in milliseconds before the first retry. */ - readonly initialDelayMs: number; - /** Multiplier applied per retry (exponential factor). */ - readonly backoffFactor: number; - /** Hard upper bound on delay in milliseconds. */ - readonly maxDelayMs: number; - /** Maximum number of retries (0-based). `null` means unlimited. */ - readonly maxRetries: number | null; -} - -/** - * Sensible defaults for WebSocket reconnect backoff. - * - * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries. - */ -export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = { - initialDelayMs: 1_000, - backoffFactor: 2, - maxDelayMs: 64_000, - maxRetries: 7, -}; - -/** - * Calculate the reconnect delay for a given retry index using exponential - * backoff. Returns `null` when `retryIndex` exceeds the configured maximum. - */ -export function getReconnectDelayMs( - retryIndex: number, - config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF, -): number | null { - if (!Number.isInteger(retryIndex) || retryIndex < 0) { - return null; - } - - if (config.maxRetries !== null && retryIndex >= config.maxRetries) { - return null; - } - - return Math.min( - Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex), - config.maxDelayMs, - ); -} diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts new file mode 100644 index 00000000000..c1703657162 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -0,0 +1,371 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + ManagedRelayRequestTimeoutError, + type ManagedRelayClientShape, +} from "./managedRelay.ts"; +import { CloudSession } from "../platform/capabilities.ts"; +import { Connectivity } from "../connection/connectivity.ts"; +import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; +import { ConnectionWakeups } from "../connection/wakeups.ts"; +import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; + +const environments = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Environment One", + endpoint: { + httpBaseUrl: "https://one.example.test", + wsBaseUrl: "wss://one.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("environment-2"), + label: "Environment Two", + endpoint: { + httpBaseUrl: "https://two.example.test", + wsBaseUrl: "wss://two.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, +] satisfies ReadonlyArray; + +function status( + environment: RelayClientEnvironmentRecord, + value: "online" | "offline", +): RelayEnvironmentStatusResponse { + return { + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: value, + checkedAt: "2026-06-01T00:00:00.000Z", + }; +} + +const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const listCalls = yield* Ref.make(0); + const listFailure = yield* Ref.make(null); + const secondListCall = yield* Deferred.make(); + const clerkToken = yield* Ref.make("clerk-token"); + const wakeups = yield* SubscriptionRef.make<{ + readonly sequence: number; + readonly reason: "application-active" | "credentials-changed"; + }>({ + sequence: 0, + reason: "application-active", + }); + const statusRequests = yield* Ref.make( + new Map>(), + ); + for (const environment of environments) { + const request = yield* Deferred.make(); + yield* Ref.update(statusRequests, (current) => { + const next = new Map(current); + next.set(environment.environmentId, request); + return next; + }); + } + + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(listCalls, (current) => current + 1); + if (count >= 2) { + yield* Deferred.succeed(secondListCall, undefined); + } + const failure = yield* Ref.get(listFailure); + if (failure) { + return yield* failure; + } + return environments; + }), + getEnvironmentStatus: ({ environmentId }) => + Ref.get(statusRequests).pipe( + Effect.flatMap((requests) => Deferred.await(requests.get(environmentId)!)), + ), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelayClientShape); + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const layer = relayEnvironmentDiscoveryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelayClient, client), + Layer.succeed( + CloudSession, + CloudSession.of({ + clerkToken: Ref.get(clerkToken).pipe( + Effect.flatMap((token) => + token === null + ? Effect.fail( + new ConnectionBlockedError({ + reason: "authentication", + message: "Signed out.", + }), + ) + : Effect.succeed(token), + ), + ), + }), + ), + Layer.succeed(Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: SubscriptionRef.changes(wakeups).pipe( + Stream.drop(1), + Stream.map((event) => event.reason), + ), + }), + ), + ), + ), + ); + + return { + layer, + listCalls, + listFailure, + clerkToken, + networkStatus, + secondListCall, + statusRequests, + wake: (reason: "application-active" | "credentials-changed") => + SubscriptionRef.update(wakeups, (event) => ({ + sequence: event.sequence + 1, + reason, + })), + }; +}); + +describe("RelayEnvironmentDiscovery", () => { + it.effect("publishes each environment status as soon as that lookup completes", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const refreshFiber = yield* Effect.forkChild(discovery.refresh); + + const checking = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === 2), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + [...checking.environments.values()].every((entry) => entry.availability === "checking"), + ).toBe(true); + + const requests = yield* Ref.get(harness.statusRequests); + yield* Deferred.succeed( + requests.get(environments[1]!.environmentId)!, + status(environments[1]!, "online"), + ); + + const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter( + (state) => + state.environments.get(environments[1]!.environmentId)?.availability === "online", + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + partiallyResolved.environments.get(environments[0]!.environmentId)?.availability, + ).toBe("checking"); + + yield* Deferred.succeed( + requests.get(environments[0]!.environmentId)!, + status(environments[0]!, "offline"), + ); + yield* Fiber.join(refreshFiber); + + const complete = yield* SubscriptionRef.get(discovery.state); + expect(complete.environments.get(environments[0]!.environmentId)?.availability).toBe( + "offline", + ); + expect(complete.refreshing).toBe(false); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect( + "preserves discovered rows while offline and refreshes after connectivity returns", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + + const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.offline), + Stream.runHead, + Effect.forkChild, + ); + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + yield* Fiber.join(offlineFiber); + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); + + yield* SubscriptionRef.set(harness.networkStatus, "online"); + yield* Deferred.await(harness.secondListCall); + expect(yield* Ref.get(harness.listCalls)).toBe(2); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("publishes listing failures without rejecting the refresh command", () => + Effect.gen(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Relay environment listing timed out.", + cause: new ManagedRelayRequestTimeoutError({ + message: "Relay environment listing timed out.", + }), + }), + ), + getEnvironmentStatus: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelayClientShape); + const layer = relayEnvironmentDiscoveryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelayClient, client), + Layer.succeed(CloudSession, { + clerkToken: Effect.succeed("clerk-token"), + }), + Layer.succeed(Connectivity, { + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }), + Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), + ), + ), + ); + + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + yield* discovery.refresh; + + const state = yield* SubscriptionRef.get(discovery.state); + expect(state.refreshing).toBe(false); + expect(Option.getOrThrow(state.error)).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("clears previously discovered rows when a refresh fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); + + yield* Ref.set( + harness.listFailure, + new ManagedRelayClientError({ + message: "Relay environment listing failed.", + }), + ); + yield* discovery.refresh; + + const failed = yield* SubscriptionRef.get(discovery.state); + expect(failed.environments.size).toBe(0); + expect(Option.isSome(failed.error)).toBe(true); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const refreshFiber = yield* Effect.forkChild(discovery.refresh); + yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === environments.length), + Stream.runHead, + ); + + yield* Ref.set(harness.clerkToken, null); + yield* harness.wake("credentials-changed"); + yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === 0), + Stream.runHead, + ); + + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* Fiber.join(refreshFiber); + yield* Effect.yieldNow; + + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); +}); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts new file mode 100644 index 00000000000..c763aef9f68 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.ts @@ -0,0 +1,333 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import { + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { ManagedRelayClient } from "./managedRelay.ts"; +import { CloudSession } from "../platform/capabilities.ts"; +import { Connectivity } from "../connection/connectivity.ts"; +import { mapManagedRelayError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { ConnectionWakeups } from "../connection/wakeups.ts"; + +export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; + +export interface RelayDiscoveredEnvironment { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: RelayEnvironmentAvailability; + readonly status: Option.Option; + readonly error: Option.Option; +} + +export interface RelayEnvironmentDiscoveryState { + readonly environments: ReadonlyMap; + readonly refreshing: boolean; + readonly offline: boolean; + readonly error: Option.Option; +} + +export interface RelayEnvironmentDiscoveryService { + readonly state: SubscriptionRef.SubscriptionRef; + readonly refresh: Effect.Effect; +} + +export class RelayEnvironmentDiscovery extends Context.Service< + RelayEnvironmentDiscovery, + RelayEnvironmentDiscoveryService +>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} + +export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), +}; + +function validateStatus( + environment: RelayClientEnvironmentRecord, + status: RelayEnvironmentStatusResponse, +): Effect.Effect { + if (status.environmentId !== environment.environmentId) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned status for a different environment.", + }), + ); + } + if ( + status.endpoint.httpBaseUrl !== environment.endpoint.httpBaseUrl || + status.endpoint.wsBaseUrl !== environment.endpoint.wsBaseUrl || + status.endpoint.providerKind !== environment.endpoint.providerKind + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned status for a different environment endpoint.", + }), + ); + } + if ( + status.descriptor !== undefined && + status.descriptor.environmentId !== environment.environmentId + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned a descriptor for a different environment.", + }), + ); + } + return Effect.succeed(status); +} + +function relayAccountId(clerkToken: string): Option.Option { + try { + return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( + Option.filter((subject) => subject.length > 0), + ); + } catch { + return Option.none(); + } +} + +const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { + const relay = yield* ManagedRelayClient; + const session = yield* CloudSession; + const connectivity = yield* Connectivity; + const wakeups = yield* ConnectionWakeups; + const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + const refreshLock = yield* Semaphore.make(1); + const hasRefreshed = yield* Ref.make(false); + const accountGeneration = yield* Ref.make(0); + const activeAccountId = yield* Ref.make>(Option.none()); + const refreshGeneration = yield* Ref.make(0); + const offlineReportFingerprints = yield* Ref.make>(new Map()); + + const clearOfflineReport = Effect.fn("RelayEnvironmentDiscovery.clearOfflineReport")(function* ( + environmentId: string, + ) { + yield* Ref.update(offlineReportFingerprints, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + }); + + const updateEnvironment = Effect.fn("RelayEnvironmentDiscovery.updateEnvironment")(function* ( + generation: number, + environmentId: string, + update: (current: RelayDiscoveredEnvironment) => RelayDiscoveredEnvironment, + ) { + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => { + const entry = current.environments.get(environmentId); + if (entry === undefined) { + return current; + } + const environments = new Map(current.environments); + environments.set(environmentId, update(entry)); + return { ...current, environments }; + }); + }); + + const refreshStatus = Effect.fn("RelayEnvironmentDiscovery.refreshStatus")(function* ( + generation: number, + clerkToken: string, + environment: RelayClientEnvironmentRecord, + ) { + const result = yield* relay + .getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }) + .pipe( + Effect.mapError(mapManagedRelayError), + Effect.flatMap((status) => validateStatus(environment, status)), + Effect.result, + ); + + if (result._tag === "Success") { + if (result.success.status === "offline") { + const fingerprint = `${result.success.endpoint.httpBaseUrl}\n${result.success.error ?? ""}`; + const shouldReport = yield* Ref.modify(offlineReportFingerprints, (current) => { + if (current.get(environment.environmentId) === fingerprint) { + return [false, current]; + } + return [true, new Map(current).set(environment.environmentId, fingerprint)]; + }); + if (shouldReport) { + yield* Effect.logWarning("Relay environment health check reported offline", { + environmentId: result.success.environmentId, + endpoint: result.success.endpoint.httpBaseUrl, + message: result.success.error, + traceId: result.success.traceId, + }); + } + } else { + yield* clearOfflineReport(environment.environmentId); + } + yield* updateEnvironment(generation, environment.environmentId, (current) => ({ + ...current, + availability: result.success.status, + status: Option.some(result.success), + error: Option.none(), + })); + return; + } + + yield* clearOfflineReport(environment.environmentId); + yield* updateEnvironment(generation, environment.environmentId, (current) => ({ + ...current, + availability: "error", + error: Option.some(result.failure), + })); + }); + + const refresh = refreshLock.withPermits(1)( + Effect.gen(function* () { + yield* Ref.set(hasRefreshed, true); + if ((yield* connectivity.status) === "offline") { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })); + return; + } + + let generation = yield* Ref.get(accountGeneration); + yield* Ref.set(refreshGeneration, generation); + yield* SubscriptionRef.set(state, { + environments: new Map(), + refreshing: true, + offline: false, + error: Option.none(), + }); + + const clerkToken = yield* session.clerkToken; + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + const accountId = relayAccountId(clerkToken); + const previousAccountId = yield* Ref.get(activeAccountId); + if ( + Option.isSome(previousAccountId) && + (!Option.isSome(accountId) || previousAccountId.value !== accountId.value) + ) { + generation = yield* Ref.updateAndGet(accountGeneration, (current) => current + 1); + yield* Ref.set(refreshGeneration, generation); + } + yield* Ref.set(activeAccountId, accountId); + + const environments = yield* relay + .listEnvironments({ clerkToken }) + .pipe(Effect.mapError(mapManagedRelayError)); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + const next = new Map(); + for (const environment of environments) { + next.set(environment.environmentId, { + environment, + availability: "checking", + status: Option.none(), + error: Option.none(), + }); + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + environments: next, + })); + + yield* Effect.forEach( + environments, + (environment) => refreshStatus(generation, clerkToken, environment), + { + concurrency: "unbounded", + discard: true, + }, + ); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + })); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const generation = yield* Ref.get(refreshGeneration); + if ((yield* Ref.get(accountGeneration)) !== generation) { + return; + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + error: Option.some(error), + })); + }), + ), + ), + ); + + yield* connectivity.changes.pipe( + Stream.changes, + Stream.runForEach((networkStatus) => + networkStatus === "offline" + ? SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })) + : Ref.get(hasRefreshed).pipe( + Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)), + ), + ), + Effect.forkScoped, + ); + yield* wakeups.changes.pipe( + Stream.runForEach((reason) => + reason === "credentials-changed" + ? Effect.gen(function* () { + yield* Ref.update(accountGeneration, (current) => current + 1); + yield* Ref.set(activeAccountId, Option.none()); + yield* Ref.set(offlineReportFingerprints, new Map()); + const shouldRefresh = yield* Ref.get(hasRefreshed); + yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + if (shouldRefresh) { + yield* refresh.pipe(Effect.forkScoped); + } + }) + : Effect.void, + ), + Effect.forkScoped, + ); + + return RelayEnvironmentDiscovery.of({ state, refresh }); +}); + +export const relayEnvironmentDiscoveryLayer = Layer.effect( + RelayEnvironmentDiscovery, + makeRelayEnvironmentDiscovery(), +); diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts new file mode 100644 index 00000000000..8e76367c601 --- /dev/null +++ b/packages/client-runtime/src/relay/index.ts @@ -0,0 +1,3 @@ +export * from "./discovery.ts"; +export * from "./managedRelay.ts"; +export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts new file mode 100644 index 00000000000..9c08c374bcd --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -0,0 +1,515 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import { + MANAGED_RELAY_REQUEST_TIMEOUT_MS, + ManagedRelayClient, + ManagedRelayDpopSigner, + managedRelayClientLayer, + type ManagedRelayAccessTokenCacheEntry, + type ManagedRelayAccessTokenStore, + type ManagedRelayDpopProofInput, +} from "./managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; + +function managedRelayTestLayer( + fetchFn: typeof globalThis.fetch, + relayUrl = "https://relay.example.test", + accessTokenStore?: ManagedRelayAccessTokenStore, +) { + const httpClientLayer = remoteHttpClientLayer(fetchFn); + const signerLayer = Layer.succeed( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("client-thumbprint"), + createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), + }), + ); + return managedRelayClientLayer({ + relayUrl, + clientId: "t3-mobile", + ...(accessTokenStore ? { accessTokenStore } : {}), + }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); +} + +function clerkToken(subject: string, nonce: string): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return `${encode({ alg: "none" })}.${encode({ sub: subject, nonce })}.signature`; +} + +describe("ManagedRelayClient", () => { + it.effect("owns tracing at service and implementation boundaries", () => { + const spanNames: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spanNames.push(span.name); + return span; + }, + }); + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(spanNames).toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.getEnvironmentStatus", + "clientRuntime.managedRelay.authorize", + "clientRuntime.managedRelay.obtainAccessToken", + "clientRuntime.managedRelay.tokenCacheCriticalSection", + "clientRuntime.managedRelay.exchangeAccessToken", + ]), + ); + expect(spanNames).not.toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.createTokenExchangeProof", + "clientRuntime.managedRelay.exchangeAccessTokenRequest", + "clientRuntime.managedRelay.createRequestProof", + ]), + ); + }).pipe(Effect.withTracer(tracer), Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("rejects unsafe relay URLs before sending credentials", () => { + let requestCount = 0; + const fetchFn = (() => { + requestCount += 1; + return Promise.resolve(Response.json({})); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay URL must be a secure absolute HTTPS origin.", + }); + expect(requestCount).toBe(0); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); + }); + + it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { + let tokenExchangeCount = 0; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: `relay-token-${tokenExchangeCount}`, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 10, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const statusInput = { + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + } as const; + + yield* relayClient.getEnvironmentStatus(statusInput); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(1); + + yield* TestClock.adjust(Duration.seconds(6)); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(2); + + yield* relayClient.resetTokenCache; + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(3); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { + let tokenExchangeCount = 0; + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelayAccessTokenStore = { + load: Effect.sync(() => persistedTokens), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.sync(() => { + persistedTokens = []; + }), + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: "persisted-relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + const statusInput = (token: string) => + ({ + clerkToken: token, + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }) as const; + + return Effect.gen(function* () { + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + expect(persistedTokens).toHaveLength(1); + + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + }); + }); + + it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => { + let tokenExchangeCount = 0; + const statusTokens: Array = []; + let persistedTokens: ReadonlyArray = [ + { + accountId: "user-1", + clientId: "t3-mobile", + relayUrl: "https://relay.example.test", + thumbprint: "client-thumbprint", + scopes: [RelayEnvironmentStatusScope], + accessToken: "stale-relay-token", + expiresAtMillis: Number.MAX_SAFE_INTEGER, + }, + ]; + const accessTokenStore: ManagedRelayAccessTokenStore = { + load: Effect.sync(() => persistedTokens), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.sync(() => { + persistedTokens = []; + }), + }; + const fetchFn = ((input, init) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: "fresh-relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + + const authorization = new Headers(init?.headers).get("authorization"); + statusTokens.push(authorization); + if (authorization === "DPoP stale-relay-token") { + return Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-stale-token", + }, + { status: 401 }, + ), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const result = yield* relayClient.getEnvironmentStatus({ + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(result.status).toBe("online"); + expect(statusTokens).toEqual(["DPoP stale-relay-token", "DPoP fresh-relay-token"]); + expect(tokenExchangeCount).toBe(1); + expect(persistedTokens).toMatchObject([ + { + accessToken: "fresh-relay-token", + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + }); + + it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelayAccessTokenStore = { + load: Effect.succeed([]), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.void, + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: "not-a-jwt", + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(persistedTokens).toEqual([]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + }); + + it.effect("times out stalled relay environment listing requests", () => { + const fetchFn = (() => + new Promise(() => undefined)) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const errorFiber = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); + }); + + it.effect("preserves typed relay trace IDs on client errors", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-managed-relay", + }, + { status: 401 }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + traceId: "trace-managed-relay", + }); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("lists account devices through the Clerk bearer client endpoint", () => { + const fetchFn = ((input, init) => { + expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); + expect(init?.headers).toMatchObject({ + authorization: "Bearer clerk-token", + }); + return Promise.resolve( + Response.json({ + devices: [ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); + expect(devices).toMatchObject([ + { + deviceId: "device-1", + label: "Julius's iPhone", + notifications: { + enabled: false, + }, + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); +}); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts new file mode 100644 index 00000000000..97484fe7d26 --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -0,0 +1,764 @@ +import { + RelayAccessTokenType, + RelayApi, + type RelayClientEnvironmentRecord, + type RelayClientDeviceRecord, + RelayConnectEnvironmentEndpoint, + type RelayDeviceRegistrationRequest, + type RelayDpopAccessTokenScope, + RelayDpopTokenExchangeGrantType, + type RelayEnvironmentConnectRequest, + type RelayEnvironmentConnectResponse, + type RelayEnvironmentLinkChallengeRequest, + type RelayEnvironmentLinkChallengeResponse, + type RelayEnvironmentLinkRequest, + type RelayEnvironmentLinkResponse, + type RelayEnvironmentStatusResponse, + RelayExchangeDpopAccessTokenEndpoint, + RelayGetEnvironmentStatusEndpoint, + RelayJwtSubjectTokenType, + type RelayLiveActivityRegistrationRequest, + RelayMobileRegistrationScope, + type RelayOkResponse, + type RelayPublicClientId, + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayProtectedError, + type RelayProtectedError as RelayProtectedErrorType, + RelayUnregisterDeviceEndpoint, +} from "@t3tools/contracts/relay"; +import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpClientError } from "effect/unstable/http"; +import type { HttpMethod } from "effect/unstable/http/HttpMethod"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +export interface ManagedRelayDpopProofInput { + readonly method: HttpMethod; + readonly url: string; + readonly accessToken?: string; +} + +export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ + readonly cause: unknown; +}> {} + +export class ManagedRelayRequestTimeoutError extends Data.TaggedError( + "ManagedRelayRequestTimeoutError", +)<{ + readonly message: string; +}> {} + +type RelayHttpRequestError = + | RelayProtectedErrorType + | HttpClientError.HttpClientError + | Schema.SchemaError + | ManagedRelayRequestTimeoutError; + +export interface ManagedRelayDpopSignerShape { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; +} + +export class ManagedRelayDpopSigner extends Context.Service< + ManagedRelayDpopSigner, + ManagedRelayDpopSignerShape +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} + +export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ + readonly message: string; + readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError; + readonly relayError?: RelayProtectedErrorType; + readonly traceId?: string; +}> {} + +export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; + +export interface ManagedRelayAccessTokenCacheEntry { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly accessToken: string; + readonly expiresAtMillis: number; +} + +export interface ManagedRelayAccessTokenStore { + readonly load: Effect.Effect>; + readonly save: (entries: ReadonlyArray) => Effect.Effect; + readonly clear: Effect.Effect; +} + +export interface ManagedRelayAuthorization { + readonly accessToken: string; + readonly proof: string; + readonly thumbprint: string; +} + +export interface ManagedRelayClientLayerOptions { + readonly relayUrl: string; + readonly clientId: RelayPublicClientId; + readonly accessTokenStore?: ManagedRelayAccessTokenStore; +} + +export interface ManagedRelayClientShape { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; +} + +export class ManagedRelayClient extends Context.Service< + ManagedRelayClient, + ManagedRelayClientShape +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} + +const isRelayProtectedError = Schema.is(RelayProtectedError); + +function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError { + return new ManagedRelayClientError({ + message, + ...(cause === undefined ? {} : { cause }), + }); +} + +function relayLocalError( + message: string, + cause: ManagedRelayDpopSignerError, +): ManagedRelayClientError { + return new ManagedRelayClientError({ message, cause }); +} + +function relayRequestError(message: string) { + return (cause: RelayHttpRequestError): ManagedRelayClientError => + new ManagedRelayClientError({ + message, + cause, + ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), + }); +} + +function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { + return ( + error.relayError?._tag === "RelayAuthInvalidError" && + error.relayError.reason === "invalid_bearer" + ); +} + +function timeoutRelayRequest(message: string) { + return ( + request: Effect.Effect, + ): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + relayClientError(message, new ManagedRelayRequestTimeoutError({ message })), + ), + onSome: Effect.succeed, + }), + ), + ); +} + +function tokenMatches( + token: ManagedRelayAccessTokenCacheEntry, + input: { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly nowMillis: number; + }, +): boolean { + return ( + token.accountId === input.accountId && + token.clientId === input.clientId && + token.relayUrl === input.relayUrl && + token.thumbprint === input.thumbprint && + token.expiresAtMillis > input.nowMillis + 5_000 && + input.scopes.every((scope) => token.scopes.includes(scope)) + ); +} + +function relayAccountId(clerkToken: string): Option.Option { + try { + return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( + Option.filter((subject) => subject.length > 0), + ); + } catch { + return Option.none(); + } +} + +function bearerHeaders(clerkToken: string) { + return { authorization: `Bearer ${clerkToken}` }; +} + +function dpopHeaders(authorization: ManagedRelayAuthorization) { + return { + authorization: `DPoP ${authorization.accessToken}`, + dpop: authorization.proof, + }; +} + +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { + const unavailable = (spanName: string) => + Effect.fn(spanName)(function* () { + return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin."); + }); + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"), + listDevices: unavailable("clientRuntime.managedRelay.listDevices"), + createEnvironmentLinkChallenge: unavailable( + "clientRuntime.managedRelay.createEnvironmentLinkChallenge", + ), + linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), + unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), + getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), + connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), + registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), + unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), + registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), + resetTokenCache: Effect.void.pipe( + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + ), + }); +} + +export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { + return Layer.effect( + ManagedRelayClient, + Effect.gen(function* () { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; + const cachedTokens = yield* SynchronizedRef.make< + ReadonlyArray + >(initialTokens.filter((token) => token.clientId === options.clientId)); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; + + const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError((cause) => + relayLocalError("Could not create relay token DPoP proof.", cause), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")), + timeoutRelayRequest("Relay DPoP access token exchange timed out."), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* relayClientError("Relay granted unexpected DPoP access token scopes."); + } + return response; + }, + ); + + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const nowMillis = yield* Clock.currentTimeMillis; + const accountId = relayAccountId(input.clerkToken); + if (Option.isNone(accountId)) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "bypass", + "relay.token_cache.bypass_reason": "invalid_subject_token", + }); + const response = yield* exchangeAccessToken(input); + return { + accountId: "", + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + } satisfies ManagedRelayAccessTokenCacheEntry; + } + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => + Effect.gen(function* () { + const activeTokens = tokens.filter( + (token) => token.expiresAtMillis > nowMillis + 5_000, + ); + const cached = activeTokens.find((token) => + tokenMatches(token, { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + nowMillis, + }), + ); + if (cached) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "hit", + }); + return [cached, activeTokens] as const; + } + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "miss", + }); + const response = yield* exchangeAccessToken(input); + const next: ManagedRelayAccessTokenCacheEntry = { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + const nextTokens = [...activeTokens, next]; + if (options.accessTokenStore) { + yield* options.accessTokenStore.save(nextTokens); + } + return [next, nextTokens] as const; + }), + ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); + }, + ); + + const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + "http.request.method": input.target.method, + "url.full": input.target.url, + }); + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError((cause) => + relayLocalError("Could not load relay DPoP proof key.", cause), + ), + ); + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError((cause) => + relayLocalError("Could not create relay request DPoP proof.", cause), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( + function* (accessToken: string) { + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { + const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); + if (nextTokens.length === tokens.length) { + return Effect.succeed([false, tokens] as const); + } + return ( + options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void + ).pipe(Effect.as([true, nextTokens] as const)); + }); + }, + ); + + const runDpopRequest = ( + input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ): Effect.Effect => { + const attempt = ( + refreshRejectedToken: boolean, + ): Effect.Effect => + authorize(input).pipe( + Effect.flatMap((authorization) => + request(authorization).pipe( + Effect.catch((error) => { + if (!isRejectedDpopAccessToken(error)) { + return Effect.fail(error); + } + return invalidateAccessToken(authorization.accessToken).pipe( + Effect.tap((invalidated) => + Effect.annotateCurrentSpan({ + "relay.token_cache.invalidated": invalidated, + "relay.token_cache.invalidation_reason": "invalid_bearer", + "relay.token_cache.retry_after_invalidation": refreshRejectedToken, + }), + ), + Effect.tap((invalidated) => + invalidated && refreshRejectedToken + ? Effect.logWarning( + "Relay rejected a cached DPoP access token; refreshing it once.", + ) + : Effect.void, + ), + Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), + ); + }), + ), + ), + ); + return attempt(true); + }; + + const mobileRegistrationRequest = ( + input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ) => + runDpopRequest( + { + ...input, + scopes: [RelayMobileRegistrationScope], + }, + request, + ); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("Could not list relay-managed environments.")), + timeoutRelayRequest("Relay environment listing timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), + withRelayClientTracing, + ), + listDevices: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), + }) + .pipe( + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("Could not list relay client devices.")), + timeoutRelayRequest("Relay client device listing timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listDevices"), + withRelayClientTracing, + ), + createEnvironmentLinkChallenge: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError( + relayRequestError("Could not create relay environment link challenge."), + ), + timeoutRelayRequest("Relay environment link challenge timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), + withRelayClientTracing, + ), + linkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not link relay environment.")), + timeoutRelayRequest("Relay environment linking timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), + withRelayClientTracing, + ), + unlinkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not unlink relay environment.")), + timeoutRelayRequest("Relay environment unlinking timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), + withRelayClientTracing, + ), + getEnvironmentStatus: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), + }, + (authorization) => + client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not get relay environment status.")), + timeoutRelayRequest("Relay environment status request timed out."), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), + withRelayClientTracing, + ), + connectEnvironment: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), + }, + (authorization) => { + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not connect relay environment.")), + timeoutRelayRequest("Relay environment connection timed out."), + ); + }, + ); + }, + Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), + withRelayClientTracing, + ), + registerDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }, + (authorization) => + client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not register relay mobile device.")), + timeoutRelayRequest("Relay mobile device registration timed out."), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerDevice"), + withRelayClientTracing, + ), + unregisterDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), + }, + (authorization) => + client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not unregister relay mobile device.")), + timeoutRelayRequest("Relay mobile device unregistration timed out."), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), + withRelayClientTracing, + ), + registerLiveActivity: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), + }, + (authorization) => + client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not register relay live activity.")), + timeoutRelayRequest("Relay Live Activity registration timed out."), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), + withRelayClientTracing, + ), + resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( + Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + withRelayClientTracing, + ), + }); + }), + ); +} diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts new file mode 100644 index 00000000000..43b020d0840 --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -0,0 +1,383 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientDeviceRecord, + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, vi } from "vite-plus/test"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + type ManagedRelayClientShape, +} from "./managedRelay.ts"; +import { + createManagedRelayQueryManager, + createManagedRelaySession, + managedRelayAccountChanges, + type ManagedRelayQueryEvent, + managedRelaySessionAtom, + readManagedRelaySnapshotState, + setManagedRelaySession, + waitForManagedRelayClerkToken, +} from "./managedRelayState.ts"; + +let registry = AtomRegistry.make(); + +const environment = { + environmentId: EnvironmentId.make("environment-1"), + label: "Main environment", + endpoint: { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientEnvironmentRecord; + +const device = { + deviceId: "device-1", + label: "Julius iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: null, + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientDeviceRecord; + +function resetRegistry() { + registry.dispose(); + registry = AtomRegistry.make(); +} + +function createManager( + overrides?: Partial, + onQueryEvent?: (event: ManagedRelayQueryEvent) => void, +) { + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.succeed([environment]), + listDevices: () => Effect.succeed([device]), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => + Effect.succeed({ + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + }), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + ...overrides, + }); + const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); + return createManagedRelayQueryManager(runtime, { + staleTimeMs: 60_000, + ...(onQueryEvent ? { onQueryEvent } : {}), + }); +} + +function setSession() { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("clerk-token"), + }); +} + +function clerkToken(expiresAtSeconds: number): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); + return `${encode({ alg: "none" })}.${encode({ exp: expiresAtSeconds })}.signature`; +} + +describe("createManagedRelayQueryManager", () => { + afterEach(resetRegistry); + + it.effect("waits for the current cloud session before reading its token", () => + Effect.gen(function* () { + const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); + + setSession(); + + expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); + }), + ); + + it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => + Effect.gen(function* () { + const token = clerkToken(4_102_444_800); + let resolveToken!: (value: string) => void; + const readClerkToken = vi.fn( + () => + new Promise((resolve) => { + resolveToken = resolve; + }), + ); + const session = createManagedRelaySession({ + accountId: "account-1", + readClerkToken, + }); + + const readsFiber = yield* Effect.all([session.readClerkToken(), session.readClerkToken()], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(readClerkToken).toHaveBeenCalledTimes(1); + + resolveToken(token); + expect(yield* Fiber.join(readsFiber)).toEqual([token, token]); + expect(yield* session.readClerkToken()).toBe(token); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("updates the token provider without replacing a same-account session", () => + Effect.gen(function* () { + const firstRead = vi.fn(() => Promise.resolve(null)); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: firstRead, + }); + const firstSession = registry.get(managedRelaySessionAtom); + expect(firstSession).not.toBeNull(); + expect(yield* firstSession!.readClerkToken()).toBeNull(); + + const secondRead = vi.fn(() => Promise.resolve("refreshed-token")); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: secondRead, + }); + + expect(registry.get(managedRelaySessionAtom)).toBe(firstSession); + expect(yield* firstSession!.readClerkToken()).toBe("refreshed-token"); + expect(firstRead).toHaveBeenCalledTimes(1); + expect(secondRead).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("does not pin a refreshed session to an older pending token read", () => + Effect.gen(function* () { + let resolveFirst!: (token: string) => void; + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + }); + const session = registry.get(managedRelaySessionAtom); + const firstRead = yield* session!.readClerkToken().pipe(Effect.forkChild); + yield* Effect.yieldNow; + + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + + expect(yield* session!.readClerkToken()).toBe("refreshed-token"); + resolveFirst("older-token"); + expect(yield* Fiber.join(firstRead)).toBe("older-token"); + }), + ); + + it("emits credential changes only when the managed relay account changes", async () => { + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("first-token"), + }); + const changes = Effect.runPromise( + managedRelayAccountChanges(registry).pipe(Stream.take(2), Stream.runCollect), + ); + await vi.waitFor(() => { + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBeGreaterThan(0); + }); + + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken: () => Promise.resolve("refreshed-token"), + }); + setManagedRelaySession(registry, { + accountId: "account-2", + readClerkToken: () => Promise.resolve("second-token"), + }); + setManagedRelaySession(registry, null); + + expect(Array.from(await changes)).toEqual(["account-2", null]); + }); + + it("shares one Clerk token read across concurrent relay list and status queries", async () => { + const secondEnvironment = { + ...environment, + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + endpoint: { + ...environment.endpoint, + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", + }, + } satisfies RelayClientEnvironmentRecord; + const token = clerkToken(4_102_444_800); + const readClerkToken = vi.fn(() => Promise.resolve(token)); + const manager = createManager({ + listEnvironments: () => Effect.succeed([environment, secondEnvironment]), + getEnvironmentStatus: ({ environmentId }) => { + const current = + environmentId === environment.environmentId ? environment : secondEnvironment; + return Effect.succeed({ + environmentId: current.environmentId, + endpoint: current.endpoint, + status: "online" as const, + checkedAt: "2026-06-01T00:00:00.000Z", + }); + }, + }); + setManagedRelaySession(registry, { + accountId: "account-1", + readClerkToken, + }); + + const environmentsAtom = manager.environmentsAtom("account-1"); + const firstStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment, + }); + const secondStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment: secondEnvironment, + }); + registry.get(environmentsAtom); + registry.get(firstStatusAtom); + registry.get(secondStatusAtom); + + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(firstStatusAtom)).data?.status).toBe( + "online", + ); + expect(readManagedRelaySnapshotState(registry.get(secondStatusAtom)).data?.status).toBe( + "online", + ); + }); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }); + + it("keeps environment snapshots cached and refreshes them explicitly", async () => { + const listEnvironments = vi.fn(() => Effect.succeed([environment])); + const manager = createManager({ listEnvironments }); + setSession(); + const atom = manager.environmentsAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); + + registry.get(manager.environmentsAtom("account-1")); + expect(listEnvironments).toHaveBeenCalledTimes(1); + + manager.refreshEnvironments(registry, "account-1"); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); + }); + + it("loads device snapshots through the current account session", async () => { + const listDevices = vi.fn(() => Effect.succeed([device])); + const manager = createManager({ listDevices }); + setSession(); + const atom = manager.devicesAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); + }); + }); + + it("reports token and relay request phases for environment status queries", async () => { + const onQueryEvent = vi.fn(); + const manager = createManager(undefined, onQueryEvent); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data?.status).toBe("online"); + }); + + expect(onQueryEvent).toHaveBeenCalledWith({ + operation: "environment-status", + stage: "clerk-token", + phase: "start", + accountId: "account-1", + environmentId: environment.environmentId, + }); + expect(onQueryEvent).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "environment-status", + stage: "relay-request", + phase: "success", + accountId: "account-1", + environmentId: environment.environmentId, + }), + ); + }); + + it("rejects status responses for a different environment", async () => { + const mismatchedStatus = { + environmentId: EnvironmentId.make("environment-2"), + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + } satisfies RelayEnvironmentStatusResponse; + const manager = createManager({ + getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + "Relay returned status for a different environment.", + ); + }); + }); + + it("exposes relay trace IDs alongside snapshot errors", async () => { + const manager = createManager({ + getEnvironmentStatus: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Could not get relay environment status.", + traceId: "trace-status", + }), + ), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ + error: "Could not get relay environment status.", + errorTraceId: "trace-status", + }); + }); + }); +}); diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts similarity index 51% rename from packages/client-runtime/src/managedRelayState.ts rename to packages/client-runtime/src/relay/managedRelayState.ts index f9cfab82594..8a26d2f698f 100644 --- a/packages/client-runtime/src/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -6,28 +6,55 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, } from "@t3tools/contracts/relay"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { findErrorTraceId } from "../errors/errorTrace.ts"; import { ManagedRelayClient } from "./managedRelay.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; +const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; export interface ManagedRelaySession { readonly accountId: string; readonly readClerkToken: () => Effect.Effect; } +export interface ManagedRelaySessionInput { + readonly accountId: string; + readonly readClerkToken: () => Promise; +} + +interface ManagedRelaySessionControl { + readonly updateReadClerkToken: ( + readClerkToken: ManagedRelaySessionInput["readClerkToken"], + ) => void; +} + export interface ManagedRelaySnapshotState { readonly data: A | null; readonly error: string | null; + readonly errorTraceId: string | null; readonly isPending: boolean; } +export interface ManagedRelayQueryEvent { + readonly operation: "environments" | "devices" | "environment-status"; + readonly stage: "clerk-token" | "relay-request" | "validation"; + readonly phase: "start" | "success" | "failure"; + readonly accountId: string; + readonly environmentId?: string; + readonly message?: string; + readonly traceId?: string | null; +} + export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ readonly message: string; readonly cause?: unknown; @@ -42,29 +69,107 @@ export const managedRelaySessionAtom = Atom.make(nul Atom.withLabel("managed-relay:session"), ); -export function createManagedRelaySession(input: { - readonly accountId: string; - readonly readClerkToken: () => Promise; -}): ManagedRelaySession { - return { +const managedRelaySessionControls = new WeakMap(); + +export function createManagedRelaySession(input: ManagedRelaySessionInput): ManagedRelaySession { + let cachedToken: { readonly token: string; readonly expiresAtMillis: number } | null = null; + let pendingToken: Promise | null = null; + let readClerkToken = input.readClerkToken; + let tokenProviderGeneration = 0; + + const readCachedClerkToken = async (nowMillis: number): Promise => { + if (cachedToken && cachedToken.expiresAtMillis > nowMillis + CLERK_TOKEN_EXPIRY_SKEW_MS) { + return cachedToken.token; + } + if (pendingToken) { + return await pendingToken; + } + + const operationGeneration = tokenProviderGeneration; + const operation = readClerkToken().then((token) => { + if (operationGeneration !== tokenProviderGeneration) { + return token; + } + if (!token) { + cachedToken = null; + return null; + } + try { + const expiresAtSeconds = decodeRelayJwt(token).exp; + cachedToken = + typeof expiresAtSeconds === "number" + ? { token, expiresAtMillis: expiresAtSeconds * 1_000 } + : null; + } catch { + cachedToken = null; + } + return token; + }); + pendingToken = operation; + try { + return await operation; + } finally { + if (pendingToken === operation) { + pendingToken = null; + } + } + }; + + const session: ManagedRelaySession = { accountId: input.accountId, - readClerkToken: () => - Effect.tryPromise({ - try: input.readClerkToken, + readClerkToken: Effect.fn("clientRuntime.managedRelaySession.readClerkToken")(function* () { + const nowMillis = yield* Clock.currentTimeMillis; + return yield* Effect.tryPromise({ + try: () => readCachedClerkToken(nowMillis), catch: (cause) => new ManagedRelaySessionError({ - message: "Could not obtain the T3 Connect session token.", + message: "Could not obtain the T3 Cloud session token.", cause, }), - }), + }); + }), }; + managedRelaySessionControls.set(session, { + updateReadClerkToken: (nextReadClerkToken) => { + readClerkToken = nextReadClerkToken; + tokenProviderGeneration += 1; + pendingToken = null; + }, + }); + return session; } export function setManagedRelaySession( registry: AtomRegistry.AtomRegistry, - session: ManagedRelaySession | null, + input: ManagedRelaySessionInput | null, ): void { - registry.set(managedRelaySessionAtom, session); + const current = registry.get(managedRelaySessionAtom); + if (input === null) { + if (current !== null) { + registry.set(managedRelaySessionAtom, null); + } + return; + } + if (current?.accountId === input.accountId) { + const control = managedRelaySessionControls.get(current); + if (control) { + // Clerk can replace its token reader during routine same-account refreshes. + // Keep the session stable so those refreshes do not invalidate queries or reconnect leases. + control.updateReadClerkToken(input.readClerkToken); + return; + } + } + registry.set(managedRelaySessionAtom, createManagedRelaySession(input)); +} + +export function managedRelayAccountChanges( + registry: AtomRegistry.AtomRegistry, +): Stream.Stream { + return AtomRegistry.toStream(registry, managedRelaySessionAtom).pipe( + Stream.map((session) => session?.accountId ?? null), + Stream.changes, + Stream.drop(1), + ); } function readSessionClerkToken( @@ -76,17 +181,17 @@ function readSessionClerkToken( ? Effect.succeed(token) : Effect.fail( new ManagedRelaySessionError({ - message: "The T3 Connect session token is unavailable.", + message: "The T3 Cloud session token is unavailable.", }), ), ), ); } -export function waitForManagedRelayClerkToken( - registry: AtomRegistry.AtomRegistry, -): Effect.Effect { - return Effect.callback((resume) => { +export const waitForManagedRelayClerkToken = Effect.fn( + "clientRuntime.managedRelaySession.waitForClerkToken", +)(function* (registry: AtomRegistry.AtomRegistry) { + return yield* Effect.callback((resume) => { let unsubscribe: (() => void) | undefined; let completed = false; const readCurrentSession = () => { @@ -111,7 +216,7 @@ export function waitForManagedRelayClerkToken( readCurrentSession(); return Effect.sync(() => unsubscribe?.()); }); -} +}); function requireClerkToken( get: Atom.AtomContext, @@ -121,7 +226,7 @@ function requireClerkToken( if (!session || session.accountId !== accountId) { return Effect.fail( new ManagedRelaySessionError({ - message: "Sign in to T3 Connect before loading relay data.", + message: "Sign in to T3 Cloud before loading relay data.", }), ); } @@ -188,13 +293,16 @@ export function readManagedRelaySnapshotState( result: AsyncResult.AsyncResult, ): ManagedRelaySnapshotState { let error: string | null = null; + let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; + error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + errorTraceId = findErrorTraceId(cause); } return { data: Option.getOrNull(AsyncResult.value(result)), error, + errorTraceId, isPending: result.waiting, }; } @@ -204,18 +312,50 @@ export function createManagedRelayQueryManager( options?: { readonly staleTimeMs?: number; readonly idleTtlMs?: number; + readonly onQueryEvent?: (event: ManagedRelayQueryEvent) => void; }, ) { const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + const observe = ( + input: Omit, + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + options?.onQueryEvent?.({ ...input, phase: "start" }); + return yield* effect.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (exit._tag === "Success") { + options?.onQueryEvent?.({ ...input, phase: "success" }); + return; + } + const error = Cause.squash(exit.cause); + options?.onQueryEvent?.({ + ...input, + phase: "failure", + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + }); + }), + ), + ); + }); const environmentsAtom = Atom.family((accountId: string) => runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); + const base = { operation: "environments" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); const relay = yield* ManagedRelayClient; - return yield* relay.listEnvironments({ clerkToken }); + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listEnvironments({ clerkToken }), + ); }), ) .pipe( @@ -229,9 +369,16 @@ export function createManagedRelayQueryManager( runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); + const base = { operation: "devices" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); const relay = yield* ManagedRelayClient; - return yield* relay.listDevices({ clerkToken }); + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listDevices({ clerkToken }), + ); }), ) .pipe( @@ -246,14 +393,28 @@ export function createManagedRelayQueryManager( return runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); - const relay = yield* ManagedRelayClient; - const status = yield* relay.getEnvironmentStatus({ - clerkToken, - scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + const base = { + operation: "environment-status" as const, + accountId, environmentId: environment.environmentId, - }); - return yield* validateEnvironmentStatus(environment, status); + }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelayClient; + const status = yield* observe( + { ...base, stage: "relay-request" }, + relay.getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }), + ); + return yield* observe( + { ...base, stage: "validation" }, + validateEnvironmentStatus(environment, status), + ); }), ) .pipe( diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts deleted file mode 100644 index 69e6d2a54a1..00000000000 --- a/packages/client-runtime/src/remote.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - AuthAccessTokenType, - type AuthClientPresentationMetadata, - AuthEnvironmentBootstrapTokenType, - AuthTokenExchangeGrantType, - type AuthEnvironmentScope, - EnvironmentHttpApi, - EnvironmentHttpCommonError, -} from "@t3tools/contracts"; -import type { - EnvironmentAuthInvalidError, - EnvironmentInternalError, - EnvironmentOperationForbiddenError, - EnvironmentRequestInvalidError, - EnvironmentScopeRequiredError, -} from "@t3tools/contracts"; -import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); - -export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { - const url = new URL(httpBaseUrl); - url.pathname = pathname; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const remoteApiBaseUrl = (httpBaseUrl: string): string => { - const url = new URL(httpBaseUrl); - url.pathname = "/"; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const clientMetadataTokenExchangeFields = ( - clientMetadata: AuthClientPresentationMetadata | undefined, -) => ({ - ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), - ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), - ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), -}); - -export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( - "RemoteEnvironmentAuthFetchError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( - "RemoteEnvironmentAuthInvalidJsonError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( - "RemoteEnvironmentAuthUndeclaredStatusError", -)<{ - readonly message: string; - readonly status: number; - readonly requestUrl: string; -}> { - constructor(requestUrl: string, status: number) { - super({ - message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`, - requestUrl, - status, - }); - } -} - -export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( - "RemoteEnvironmentAuthTimeoutError", -)<{ - readonly message: string; - readonly requestUrl: string; - readonly timeoutMs: number; -}> { - constructor(requestUrl: string, timeoutMs: number) { - super({ - message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, - requestUrl, - timeoutMs, - }); - } -} - -export type RemoteEnvironmentAuthError = - | EnvironmentRequestInvalidError - | EnvironmentAuthInvalidError - | EnvironmentScopeRequiredError - | EnvironmentOperationForbiddenError - | EnvironmentInternalError - | RemoteEnvironmentAuthFetchError - | RemoteEnvironmentAuthInvalidJsonError - | RemoteEnvironmentAuthUndeclaredStatusError - | RemoteEnvironmentAuthTimeoutError; - -export const remoteHttpClientLayer = ( - fetchFn: typeof globalThis.fetch, -): Layer.Layer => - Layer.merge( - FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), - httpHeaderRedactionLayer, - ); - -const failRemoteRequest = ( - requestUrl: string, - cause: unknown, -): Effect.Effect => { - if (cause instanceof RemoteEnvironmentAuthTimeoutError) { - return Effect.fail(cause); - } - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail(cause); - } - if (Schema.isSchemaError(cause)) { - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - const response = cause.response; - if (response.status < 200 || response.status >= 300) { - return Effect.fail( - new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthFetchError({ - message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, - cause, - }), - ); -}; - -const executeRemoteRequest = ( - requestUrl: string, - timeoutMs: number, - request: Effect.Effect, -): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), - onSome: Effect.succeed, - }), - ), - Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), - ); - -export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => - HttpApiClient.make(EnvironmentHttpApi, { - baseUrl: remoteApiBaseUrl(httpBaseUrl), - }); - -export const exchangeRemoteDpopAccessToken = Effect.fn( - "clientRuntime.remote.exchangeRemoteDpopAccessToken", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - const response = yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: { dpop: input.dpopProof }, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); - return response; -}); - -export const bootstrapRemoteBearerSession = Effect.fn( - "clientRuntime.remote.bootstrapRemoteBearerSession", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: {}, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); -}); - -export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( - function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; - }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); - }, -); - -export const fetchRemoteDpopSessionState = Effect.fn( - "clientRuntime.remote.fetchRemoteDpopSessionState", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const fetchRemoteEnvironmentDescriptor = Effect.fn( - "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", -)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.metadata.descriptor(), - ); -}); - -export const issueRemoteWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); -}); - -export const issueRemoteDpopWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteDpopWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const resolveRemoteWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - bearerToken: input.bearerToken, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); - -export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteDpopWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - accessToken: input.accessToken, - dpopProof: input.dpopProof, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts new file mode 100644 index 00000000000..dff78cefae5 --- /dev/null +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -0,0 +1,391 @@ +import { + EnvironmentId, + type RelayClientInstallProgressEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { RpcClientError } from "effect/unstable/rpc"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const INSTALL_CHECKING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "checking", +}; +const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "downloading", +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { + const state = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const retryCount = yield* Ref.make(0); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state, + session: activeSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisorService); + return { + activeSession, + retryCount, + supervisor, + }; +}); + +describe("environment RPC", () => { + it.effect("observes unary requests until they complete", () => + Effect.gen(function* () { + const observations: string[] = []; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.succeed({ status: "available", version: "2026.6.0" }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + observations.push(`start:${environmentId}:${method}`); + return Effect.sync(() => { + observations.push(`finish:${environmentId}:${method}`); + }); + }), + }), + ), + ); + + expect(result).toEqual({ status: "available", version: "2026.6.0" }); + expect(observations).toEqual([ + `start:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + `finish:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + ]); + }), + ); + + it.effect("binds finite streaming commands to one active session", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded(); + const secondEvents = yield* Queue.unbounded(); + const firstClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.take(2), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Queue.offer(firstEvents, INSTALL_CHECKING); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* Queue.offer(secondEvents, INSTALL_DOWNLOADING); + yield* Queue.offer(firstEvents, INSTALL_DOWNLOADING); + + expect(yield* Fiber.join(resultFiber)).toEqual([INSTALL_CHECKING, INSTALL_DOWNLOADING]); + }), + ); + + it.effect("switches durable subscriptions when the supervisor replaces the session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + const awaitSubscriptions = Effect.fn("TestEnvironmentRpc.awaitSubscriptions")(function* ( + count: number, + ) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (subscriptions.length >= count) { + return; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error(`Expected ${count} durable subscriptions.`)); + }); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + yield* awaitSubscriptions(1); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* awaitSubscriptions(2); + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }), + ); + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + for (let attempt = 0; attempt < 100 && subscriptions.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + yield* SubscriptionRef.set(activeSession, Option.none()); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("surfaces domain subscription failures without reconnecting", () => + Effect.gen(function* () { + const domainError = new Error("terminal subscription rejected"); + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => Stream.fail(domainError), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.flip, + ); + + expect(error).toBe(domainError); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("keeps handled domain failures dormant until a replacement session arrives", () => + Effect.gen(function* () { + const domainError = new Error("terminal subscription rejected"); + const subscriptions: string[] = []; + const observedFailures: Error[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.fail(domainError); + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: (cause) => + Effect.sync(() => { + observedFailures.push(Cause.squash(cause) as Error); + }), + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(subscriptions).toEqual(["first"]); + expect(observedFailures).toEqual([domainError]); + + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("retries handled domain failures within the same session when configured", () => + Effect.gen(function* () { + const domainError = new Error("thread not found yet"); + const subscriptionCount = yield* Ref.make(0); + const expectedFailureCount = yield* Ref.make(0); + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Ref.getAndUpdate(subscriptionCount, (count) => count + 1).pipe( + Effect.map((count) => (count === 0 ? Stream.fail(domainError) : Stream.never)), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Ref.update(expectedFailureCount, (count) => count + 1), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(expectedFailureCount)) >= 1) { + break; + } + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(subscriptionCount)).toBe(1); + expect(yield* Ref.get(expectedFailureCount)).toBe(1); + + yield* TestClock.adjust("100 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(expectedFailureCount)).toBe(1); + }), + ); + + it.effect("does not classify subscription defects as expected failures", () => + Effect.gen(function* () { + const defect = new Error("subscription invariant failed"); + let expectedFailureCount = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const exit = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => + Effect.sync(() => { + expectedFailureCount += 1; + }), + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + } + expect(expectedFailureCount).toBe(0); + }), + ); +}); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts new file mode 100644 index 00000000000..882d8f51b53 --- /dev/null +++ b/packages/client-runtime/src/rpc/client.ts @@ -0,0 +1,247 @@ +import { ORCHESTRATION_WS_METHODS, type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { RpcClientError } from "effect/unstable/rpc"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; + +export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( + "EnvironmentRpcUnavailableError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export interface EnvironmentRpcRequestObservation { + readonly environmentId: string; + readonly method: string; +} + +export class EnvironmentRpcRequestObserver extends Context.Reference<{ + readonly observe: ( + request: EnvironmentRpcRequestObservation, + ) => Effect.Effect>; +}>("@t3tools/client-runtime/rpc/EnvironmentRpcRequestObserver", { + defaultValue: () => ({ + observe: () => Effect.succeed(Effect.void), + }), +}) {} + +export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; +type RpcMethod = WsRpcProtocolClient[TTag]; + +export type EnvironmentSubscriptionRpcTag = + | typeof ORCHESTRATION_WS_METHODS.subscribeShell + | typeof ORCHESTRATION_WS_METHODS.subscribeThread + | typeof WS_METHODS.subscribeAuthAccess + | typeof WS_METHODS.subscribeServerConfig + | typeof WS_METHODS.subscribeServerLifecycle + | typeof WS_METHODS.subscribeTerminalEvents + | typeof WS_METHODS.subscribeTerminalMetadata + | typeof WS_METHODS.subscribePreviewEvents + | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.previewAutomationConnect + | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.terminalAttach; + +export type EnvironmentStreamCommandRpcTag = + | typeof WS_METHODS.cloudInstallRelayClient + | typeof WS_METHODS.gitRunStackedAction; + +export type EnvironmentStreamRpcTag = + | EnvironmentSubscriptionRpcTag + | EnvironmentStreamCommandRpcTag; + +export type EnvironmentUnaryRpcTag = Exclude; +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); + +export type EnvironmentRpcInput = Parameters>[0]; + +export type EnvironmentRpcSuccess = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? A + : never; + +export type EnvironmentRpcFailure = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? E + : never; + +export type EnvironmentRpcStreamValue = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? A + : never; + +export type EnvironmentRpcStreamFailure = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? E + : never; + +const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { + const supervisor = yield* EnvironmentSupervisor; + return yield* SubscriptionRef.get(supervisor.session).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: `${supervisor.target.label} is not connected.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); +}); + +export const request = Effect.fn("EnvironmentRpc.request")(function* < + TTag extends EnvironmentUnaryRpcTag, +>(tag: TTag, input: EnvironmentRpcInput) { + const supervisor = yield* EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + const observer = yield* EnvironmentRpcRequestObserver; + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Effect.Effect, EnvironmentRpcFailure>; + const completeObservation = yield* observer.observe({ + environmentId: supervisor.target.environmentId, + method: tag, + }); + return yield* method(input).pipe(Effect.ensuring(completeObservation)); +}); + +export function runStream( + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentRpcUnavailableError, + EnvironmentSupervisor +> { + return Stream.unwrap( + currentSession().pipe( + Effect.map((session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return method(input); + }), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.runStream", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export function subscribe( + tag: TTag, + input: EnvironmentRpcInput, + options?: { + readonly onExpectedFailure?: ( + cause: Cause.Cause>, + ) => Effect.Effect; + readonly retryExpectedFailureAfter?: Duration.Input; + }, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + >; + const subscribeToSession = (): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + > => + Stream.suspend(() => + method(input).pipe( + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sleep(options.retryExpectedFailureAfter), + ).pipe(Stream.drain), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), + ), + ); + return subscribeToSession(); + }, + }), + ), + ), + ), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.subscribe", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export const config: Effect.Effect< + ServerConfig, + EnvironmentRpcUnavailableError | ConnectionAttemptError, + EnvironmentSupervisor +> = Effect.gen(function* () { + const session = yield* currentSession(); + return yield* session.initialConfig; +}).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts new file mode 100644 index 00000000000..11bfa794fa7 --- /dev/null +++ b/packages/client-runtime/src/rpc/http.ts @@ -0,0 +1,154 @@ +import { + EnvironmentHttpApi, + EnvironmentHttpCommonError, + type EnvironmentAuthInvalidError, + type EnvironmentInternalError, + type EnvironmentOperationForbiddenError, + type EnvironmentRequestInvalidError, + type EnvironmentScopeRequiredError, +} from "@t3tools/contracts"; +import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); + +export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( + "RemoteEnvironmentAuthFetchError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( + "RemoteEnvironmentAuthInvalidJsonError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( + "RemoteEnvironmentAuthUndeclaredStatusError", +)<{ + readonly message: string; + readonly status: number; + readonly requestUrl: string; +}> { + constructor(requestUrl: string, status: number) { + super({ + message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`, + requestUrl, + status, + }); + } +} + +export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( + "RemoteEnvironmentAuthTimeoutError", +)<{ + readonly message: string; + readonly requestUrl: string; + readonly timeoutMs: number; +}> { + constructor(requestUrl: string, timeoutMs: number) { + super({ + message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, + requestUrl, + timeoutMs, + }); + } +} + +export type RemoteEnvironmentRequestError = + | EnvironmentRequestInvalidError + | EnvironmentAuthInvalidError + | EnvironmentScopeRequiredError + | EnvironmentOperationForbiddenError + | EnvironmentInternalError + | RemoteEnvironmentAuthFetchError + | RemoteEnvironmentAuthInvalidJsonError + | RemoteEnvironmentAuthUndeclaredStatusError + | RemoteEnvironmentAuthTimeoutError; + +export const remoteHttpClientLayer = ( + fetchFn: typeof globalThis.fetch, +): Layer.Layer => + Layer.merge( + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), + httpHeaderRedactionLayer, + ); + +const remoteApiBaseUrl = (httpBaseUrl: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); +}; + +export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: remoteApiBaseUrl(httpBaseUrl), + }); + +const failRemoteRequest = ( + requestUrl: string, + cause: unknown, +): Effect.Effect => { + if (cause instanceof RemoteEnvironmentAuthTimeoutError) { + return Effect.fail(cause); + } + if (isEnvironmentHttpCommonError(cause)) { + return Effect.fail(cause); + } + if (Schema.isSchemaError(cause)) { + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const response = cause.response; + if (response.status < 200 || response.status >= 300) { + return Effect.fail( + new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthFetchError({ + message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`, + cause, + }), + ); +}; + +export const executeEnvironmentHttpRequest = ( + requestUrl: string, + timeoutMs: number, + request: Effect.Effect, +): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), + onSome: Effect.succeed, + }), + ), + Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), + ); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts new file mode 100644 index 00000000000..8dec2c2b2b4 --- /dev/null +++ b/packages/client-runtime/src/rpc/index.ts @@ -0,0 +1,4 @@ +export * from "./client.ts"; +export * from "./http.ts"; +export * from "./protocol.ts"; +export * from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/protocol.ts b/packages/client-runtime/src/rpc/protocol.ts new file mode 100644 index 00000000000..b8447f0d7af --- /dev/null +++ b/packages/client-runtime/src/rpc/protocol.ts @@ -0,0 +1,8 @@ +import { WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { RpcClient } from "effect/unstable/rpc"; + +export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); +type RpcClientFactory = typeof makeWsRpcProtocolClient; +export type WsRpcProtocolClient = + RpcClientFactory extends Effect.Effect ? Client : never; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts new file mode 100644 index 00000000000..0317806f9b3 --- /dev/null +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -0,0 +1,276 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ServerConfig, + type ServerConfig as ServerConfigType, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { + ConnectionTransientError, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; + +type SocketEventType = "open" | "message" | "close" | "error"; +type SocketEvent = { + readonly code?: number; + readonly data?: unknown; + readonly reason?: string; + readonly type: SocketEventType; +}; +type SocketListener = (event: SocketEvent) => void; + +class TestWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readyState = TestWebSocket.CONNECTING; + readonly sent: string[] = []; + readonly url: string; + private readonly listeners = new Map>(); + + constructor(url: string) { + this.url = url; + } + + addEventListener(type: SocketEventType, listener: SocketListener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: SocketEventType, listener: SocketListener) { + this.listeners.get(type)?.delete(listener); + } + + send(data: string) { + this.sent.push(data); + } + + close(code = 1000, reason = "") { + if (this.readyState === TestWebSocket.CLOSED) { + return; + } + this.readyState = TestWebSocket.CLOSED; + this.emit("close", { code, reason, type: "close" }); + } + + open() { + this.readyState = TestWebSocket.OPEN; + this.emit("open", { type: "open" }); + } + + serverMessage(data: string) { + this.emit("message", { data, type: "message" }); + } + + private emit(type: SocketEventType, event: SocketEvent) { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=test", + httpAuthorization: null, + target: TARGET, +}; + +const SERVER_CONFIG: ServerConfigType = { + environment: { + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/workspace", + keybindingsConfigPath: "/tmp/workspace/keybindings.json", + keybindings: [], + issues: [], + providers: [], + availableEditors: [], + observability: { + logsDirectoryPath: "/tmp/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + payload: Schema.Unknown, + tag: Schema.String, +}); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); +const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encodeServerConfig = Schema.encodeSync(ServerConfig); + +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { + const sockets: TestWebSocket[] = []; + const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { + const socket = new TestWebSocket(url); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }); + const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); + const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); + return { factory, sockets }; +}); + +const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( + sockets: ReadonlyArray, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const socket = sockets[0]; + if (socket) { + return socket; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to create a websocket.")); +}); + +const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( + socket: TestWebSocket, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const request = socket.sent[0]; + if (request) { + return decodeRpcRequest(decodeJson(request)); + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to send a request.")); +}); + +const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( + socket: TestWebSocket, +) { + const request = yield* awaitRequest(socket); + expect(request).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverGetConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: encodeServerConfig(SERVER_CONFIG), + }, + }), + ); +}); + +describe("RpcSessionFactory", () => { + it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + expect(socket.url).toBe(PREPARED.socketUrl); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const config = yield* session.initialConfig; + expect(config).toEqual(SERVER_CONFIG); + expect(socket.sent).toHaveLength(1); + + socket.close(1012, "service restart"); + const error = yield* Effect.flip(session.closed); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment disconnected.", + }); + yield* Effect.yieldNow; + expect(sockets).toHaveLength(1); + }), + ); + + it.effect("closes the websocket when the session scope is released", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + }), + ); + + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }), + ); + + it.effect("fails readiness when the websocket never opens", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + yield* awaitSocket(sockets); + + yield* TestClock.adjust("15 seconds"); + return yield* Fiber.join(readyFiber); + }), + ); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment could not establish a WebSocket connection.", + }); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts new file mode 100644 index 00000000000..2c97b75f829 --- /dev/null +++ b/packages/client-runtime/src/rpc/session.ts @@ -0,0 +1,144 @@ +import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; +import type { + ConnectionAttemptError, + ConnectionTransientError, + PreparedConnection, +} from "../connection/model.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError as ConnectionTransientErrorClass, +} from "../connection/model.ts"; + +const SOCKET_OPEN_TIMEOUT = "15 seconds"; + +export interface RpcSession { + readonly client: WsRpcProtocolClient; + readonly initialConfig: Effect.Effect; + readonly ready: Effect.Effect; + readonly probe: Effect.Effect; + readonly closed: Effect.Effect; +} + +export class RpcSessionFactory extends Context.Service< + RpcSessionFactory, + { + readonly connect: ( + connection: PreparedConnection, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/rpc/session/RpcSessionFactory") {} + +type InitialConfigError = Effect.Error< + ReturnType +>; + +function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthorizationError": + return new ConnectionBlockedError({ + reason: "permission", + message: error.message, + }); + case "KeybindingsConfigParseError": + case "ServerSettingsError": + return new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + message: error.message, + }); + case "RpcClientError": + return new ConnectionTransientErrorClass({ + reason: "transport", + message: error.message, + }); + } +} + +export const rpcSessionFactoryLayer = Layer.effect( + RpcSessionFactory, + Effect.gen(function* () { + const webSocketConstructor = yield* Socket.WebSocketConstructor; + + const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": connection.environmentId, + }); + + const connected = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const hooks = RpcClient.ConnectionHooks.of({ + onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), + onDisconnect: Deferred.isDone(connected).pipe( + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + message: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), + ), + ), + Effect.asVoid, + ), + }); + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), + ), + ), + ); + const protocolContext = yield* Layer.build(protocolLayer).pipe( + Effect.withSpan("environment.websocket.connect"), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfig = yield* Effect.cached( + client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.withSpan("environment.initialSync"), + ), + ); + const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.asVoid, + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ); + + return { + client, + initialConfig, + ready: Deferred.await(connected).pipe( + Effect.andThen(initialConfig), + Effect.asVoid, + Effect.raceFirst(Deferred.await(disconnected)), + ), + probe, + closed: Deferred.await(disconnected), + } satisfies RpcSession; + }); + + return RpcSessionFactory.of({ connect }); + }), +); diff --git a/packages/client-runtime/src/shellSnapshotState.test.ts b/packages/client-runtime/src/shellSnapshotState.test.ts deleted file mode 100644 index f7adfee6388..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; - -import { createShellSnapshotManager } from "./shellSnapshotState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const BASE_SNAPSHOT: OrchestrationShellSnapshot = { - snapshotSequence: 1, - updatedAt: "2026-04-01T00:00:00.000Z", - projects: [ - { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - }, - ], - threads: [ - { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }, - ], -}; - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createShellSnapshotManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("starts pending when marked pending", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.markPending(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - }); - }); - - it("stores snapshots", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_SNAPSHOT, - error: null, - isPending: false, - }); - }); - - it("applies incremental shell events", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - const existingThread = BASE_SNAPSHOT.threads[0]!; - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.applyEvent(TARGET, { - kind: "thread-upserted", - sequence: 2, - thread: { - ...existingThread, - title: "Renamed thread", - }, - }); - - expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread"); - expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2); - }); - - it("invalidates per environment", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - }); - }); -}); diff --git a/packages/client-runtime/src/shellSnapshotState.ts b/packages/client-runtime/src/shellSnapshotState.ts deleted file mode 100644 index e694d50e309..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; - -export interface ShellSnapshotState { - readonly data: OrchestrationShellSnapshot | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface ShellSnapshotTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownShellSnapshotKeys = new Set(); - -export const shellSnapshotStateAtom = Atom.family((key: string) => { - knownShellSnapshotKeys.add(key); - return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`shell-snapshot:${key}`), - ); -}); - -export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("shell-snapshot:null"), -); - -export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null { - return target.environmentId; -} - -export interface ShellSnapshotManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) { - function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return EMPTY_SHELL_SNAPSHOT_STATE; - } - - return config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ShellSnapshotState): void { - config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState); - } - - function markPending(target: ShellSnapshotTarget): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: null, - isPending: true, - }); - } - - function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - setState(targetKey, { - data: snapshot, - error: null, - isPending: false, - }); - } - - function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - if (current.data === null) { - return; - } - - setState(targetKey, { - data: applyShellStreamEvent(current.data, event), - error: null, - isPending: false, - }); - } - - function invalidate(target?: ShellSnapshotTarget): void { - if (target) { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE); - } - return; - } - - for (const key of knownShellSnapshotKeys) { - setState(key, EMPTY_SHELL_SNAPSHOT_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - markPending, - syncSnapshot, - applyEvent, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts deleted file mode 100644 index 9275ab64ee0..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - createSourceControlDiscoveryManager, -} from "./sourceControlDiscoveryState.ts"; - -const EMPTY_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [], - sourceControlProviders: [], -}; - -const GITHUB_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [ - { - kind: "git", - label: "Git", - implemented: true, - status: "available", - version: Option.some("2.51.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - status: "available", - version: Option.some("2.85.0"), - installHint: "Install GitHub CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("octo"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - ], -}; - -function unresolvedDiscovery() { - throw new Error("Discovery resolver was not initialized."); -} - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores refreshed discovery data in an atom snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => EMPTY_RESULT, - }), - }); - - assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight discovery refreshes by target key", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - let calls = 0; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => { - calls += 1; - return new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }, - }), - }); - - const first = manager.refresh({ key: "primary" }); - const second = manager.refresh({ key: "primary" }); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); - - resolveDiscovery(EMPTY_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps the previous snapshot when refresh fails", async () => { - let shouldFail = false; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => { - if (shouldFail) { - throw new Error("probe failed"); - } - return EMPTY_RESULT; - }, - }), - }); - - await manager.refresh({ key: "primary" }); - shouldFail = true; - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: "probe failed", - isPending: false, - }); -}); - -it("invalidates a discovery target back to the initial snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => GITHUB_RESULT, - }), - }); - - await manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("ignores an in-flight refresh after the target is invalidated", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => - new Promise((resolve) => { - resolveDiscovery = resolve; - }), - }), - }); - - const refresh = manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - resolveDiscovery(GITHUB_RESULT); - - const result = await refresh; - - assert.strictEqual(result, GITHUB_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("watches a discovery target with ref-counted client-change subscriptions", async () => { - let listener: () => void = noop; - let subscribeCalls = 0; - let unsubscribeCalls = 0; - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - subscribeClientChanges: (nextListener) => { - subscribeCalls += 1; - listener = nextListener; - return () => { - unsubscribeCalls += 1; - }; - }, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.strictEqual(subscribeCalls, 1); - assert.strictEqual(discoveryCalls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - listener(); - await flushAsyncWork(); - assert.strictEqual(discoveryCalls, 1); - - firstUnwatch(); - assert.strictEqual(unsubscribeCalls, 0); - - secondUnwatch(); - assert.strictEqual(unsubscribeCalls, 1); -}); - -it("reuses fresh watched discovery results on remount", async () => { - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(discoveryCalls, 1); -}); - -it("refreshes a watched discovery target when the resolved client is replaced", async () => { - let listener: () => void = noop; - let activeResult = EMPTY_RESULT; - let discoveryCalls = 0; - const firstClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - const secondClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - let activeClient = firstClient; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => activeClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return () => undefined; - }, - }); - - const unwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - activeClient = secondClient; - activeResult = GITHUB_RESULT; - listener(); - await flushAsyncWork(); - - assert.strictEqual(discoveryCalls, 2); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: GITHUB_RESULT, - error: null, - isPending: false, - }); - - unwatch(); -}); diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.ts b/packages/client-runtime/src/sourceControlDiscoveryState.ts deleted file mode 100644 index 105b2baf445..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -/* --- Types ---------------------------------------------------------- */ - -export interface SourceControlDiscoveryState { - readonly data: SourceControlDiscoveryResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface SourceControlDiscoveryTarget { - readonly key: TKey | null; -} - -export interface SourceControlDiscoveryClient { - readonly discoverSourceControl: () => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* --- Constants ------------------------------------------------------ */ - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -/* --- Atoms ---------------------------------------------------------- */ - -const knownSourceControlDiscoveryKeys = new Set(); - -export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { - knownSourceControlDiscoveryKeys.add(key); - return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`source-control-discovery:${key}`), - ); -}); - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, -).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); - -/* --- Helpers -------------------------------------------------------- */ - -export function getSourceControlDiscoveryTargetKey( - target: SourceControlDiscoveryTarget, -): TKey | null { - const key = target.key; - return key && key.length > 0 ? key : null; -} - -/* --- Refresh manager ------------------------------------------------ */ - -export interface SourceControlDiscoveryManagerConfig { - /** - * Get the atom registry used to read/write source-control discovery snapshots. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** - * Resolve the runtime client for a discovery target key. - * - * Web currently uses a single `"primary"` target, but keeping this keyed - * lets mobile or future multi-environment clients provide separate discovery - * clients without changing the state primitive. - */ - readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null; - /** - * Optional: subscribe to environment/client availability changes. - * - * When provided, `watch` refreshes as clients appear or are replaced - * instead of relying on React hooks to manually kick discovery. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function createSourceControlDiscoveryManager( - config: SourceControlDiscoveryManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: SourceControlDiscoveryClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - /* -- Atom helpers -------------------------------------------------- */ - - function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { - config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - const next: SourceControlDiscoveryState = - current.data === null - ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: SourceControlDiscoveryResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to discover source control tools.", - isPending: false, - }); - } - - /* -- Public API ---------------------------------------------------- */ - - /** - * Trigger a one-shot source-control discovery RPC for a target. - * - * Calls are deduplicated while a refresh for the same target key is in - * flight. On failure, the previous successful snapshot is kept in `data` - * and the error message is stored separately so UI can keep rendering stale - * discovery results while showing the failure. - * - * @param target The logical runtime target to refresh. - * @param client Optional pre-resolved client, useful in tests. - */ - function refresh( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): Promise { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(targetKey); - if (!resolvedClient) { - const error = new Error("Source control discovery client is unavailable."); - setError(targetKey, error); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.discoverSourceControl().then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - /** - * Reset discovery state for one target and ignore any currently in-flight - * refresh for that target. If no target is provided, every known target is - * invalidated. - */ - function invalidate(target?: SourceControlDiscoveryTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - - /** - * Read the current atom snapshot for `target`. - * - * Invalid targets return the inert empty state rather than creating a new - * family atom entry. - */ - function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; - } - - return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - } - - /** - * Keep discovery warm for `target`. - * - * Multiple callers sharing a target key are ref-counted. With - * `subscribeClientChanges`, the manager refreshes whenever a client first - * appears or is replaced after reconnect. - */ - function watch( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): () => void { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: SourceControlDiscoveryClient | null = null; - - const sync = () => { - const resolved = config.getClient(targetKey); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(targetKey)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): void { - refreshTargets.set(targetKey, target); - if (client) { - void refresh(target, client); - return; - } - - config.getRegistry().get(watchedRefreshAtom(targetKey)); - } - - /** - * Clear in-flight refresh tracking and reset every known discovery atom. - * Primarily used by tests and runtime teardown. - */ - function reset(): void { - const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshTargets.clear(); - refreshInFlight.clear(); - for (const key of keys) { - bumpRefreshVersion(key); - setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - } - - return { - watch, - refresh, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts new file mode 100644 index 00000000000..29679d00ffe --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -0,0 +1,15 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { + makeArchivedThreadsEnvironmentKey, + parseArchivedThreadsEnvironmentKey, +} from "./archivedThreads.ts"; + +it("round-trips environment keys in sorted order", () => { + const envA = EnvironmentId.make("env-a"); + const envB = EnvironmentId.make("env-b"); + const key = makeArchivedThreadsEnvironmentKey([envB, envA]); + + expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts new file mode 100644 index 00000000000..9fbb19f632e --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -0,0 +1,30 @@ +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import { pipe } from "effect/Function"; +import * as Order from "effect/Order"; + +export interface ArchivedSnapshotEntry { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +} + +const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; +const environmentIdOrder = Order.String as Order.Order; + +export function makeArchivedThreadsEnvironmentKey( + environmentIds: ReadonlyArray, +): string { + return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => + sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + ); +} + +export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { + if (key.length === 0) { + return []; + } + return pipe( + key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + Arr.map((environmentId) => EnvironmentId.make(environmentId)), + ); +} diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts new file mode 100644 index 00000000000..1a4cf384663 --- /dev/null +++ b/packages/client-runtime/src/state/assets.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createAssetEnvironmentAtoms } from "./assets.ts"; + +describe("createAssetEnvironmentAtoms", () => { + it("keys asset URL queries by environment and resource", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const assets = createAssetEnvironmentAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const originalTarget = { + environmentId, + input: { + resource: { + _tag: "project-favicon" as const, + cwd: "/repo/original", + }, + }, + }; + + expect(assets.createUrl(originalTarget)).toBe( + assets.createUrl({ + environmentId, + input: { + resource: { + _tag: "project-favicon", + cwd: "/repo/original", + }, + }, + }), + ); + expect( + assets.createUrl({ + environmentId, + input: { + resource: { + _tag: "project-favicon", + cwd: "/repo/next", + }, + }, + }), + ).not.toBe(assets.createUrl(originalTarget)); + expect( + assets.createUrl({ + environmentId: EnvironmentId.make("environment-2"), + input: originalTarget.input, + }), + ).not.toBe(assets.createUrl(originalTarget)); + }); + + it("keys collections while preserving independent resource queries", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const assets = createAssetEnvironmentAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const resources = [ + { _tag: "attachment" as const, attachmentId: "attachment-1" }, + { _tag: "attachment" as const, attachmentId: "attachment-2" }, + ]; + + expect(assets.createUrls({ environmentId, resources })).toBe( + assets.createUrls({ + environmentId, + resources: resources.map((resource) => ({ ...resource })), + }), + ); + expect( + assets.createUrls({ + environmentId, + resources: [...resources].toReversed(), + }), + ).not.toBe(assets.createUrls({ environmentId, resources })); + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts new file mode 100644 index 00000000000..6863de9055f --- /dev/null +++ b/packages/client-runtime/src/state/assets.ts @@ -0,0 +1,54 @@ +import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; +const ASSET_URL_STALE_TIME_MS = 5 * 60_000; +const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; + +export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { + try { + return new URL(relativeUrl, httpBaseUrl).toString(); + } catch { + return null; + } +} + +export function createAssetEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:assets:create-url", + tag: WS_METHODS.assetsCreateUrl, + staleTimeMs: ASSET_URL_STALE_TIME_MS, + idleTtlMs: ASSET_URL_IDLE_TTL_MS, + refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, + }); + const createUrlsFamily = Atom.family((key: string) => { + const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray]; + const environmentId = EnvironmentId.make(rawEnvironmentId); + return Atom.make((get) => + resources.map((resource) => + get( + createUrl({ + environmentId, + input: { resource }, + }), + ), + ), + ).pipe( + Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS), + Atom.withLabel(`environment-data:assets:create-urls:${key}`), + ); + }); + + return { + createUrl, + createUrls: (target: { + readonly environmentId: EnvironmentId; + readonly resources: ReadonlyArray; + }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), + }; +} diff --git a/packages/client-runtime/src/state/auth.test.ts b/packages/client-runtime/src/state/auth.test.ts new file mode 100644 index 00000000000..b31fe617912 --- /dev/null +++ b/packages/client-runtime/src/state/auth.test.ts @@ -0,0 +1,79 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import { applyAuthAccessStreamEvent, EMPTY_AUTH_ACCESS_SNAPSHOT } from "./auth.ts"; + +describe("applyAuthAccessStreamEvent", () => { + it("accumulates rapid pairing-link and client updates into one snapshot", () => { + const pairingLink = { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + } as const; + const clientSession = { + sessionId: AuthSessionId.make("session-client"), + subject: "subject", + scopes: ["orchestration:read"], + method: "browser-session-cookie", + client: { + label: "Phone", + deviceType: "mobile", + }, + issuedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-05-07T00:00:00.000Z"), + lastConnectedAt: null, + connected: true, + current: false, + } as const; + + const withPairingLink = applyAuthAccessStreamEvent(EMPTY_AUTH_ACCESS_SNAPSHOT, { + version: 1, + revision: 1, + type: "pairingLinkUpserted", + payload: pairingLink, + }); + const withClient = applyAuthAccessStreamEvent(withPairingLink, { + version: 1, + revision: 2, + type: "clientUpserted", + payload: clientSession, + }); + + expect(withClient).toEqual({ + pairingLinks: [pairingLink], + clientSessions: [clientSession], + }); + }); + + it("applies removals without disturbing unrelated access state", () => { + const snapshot = applyAuthAccessStreamEvent( + { + pairingLinks: [ + { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + }, + ], + clientSessions: [], + }, + { + version: 1, + revision: 2, + type: "pairingLinkRemoved", + payload: { id: "pairing-link" }, + }, + ); + + expect(snapshot).toEqual(EMPTY_AUTH_ACCESS_SNAPSHOT); + }); +}); diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts new file mode 100644 index 00000000000..074b89627af --- /dev/null +++ b/packages/client-runtime/src/state/auth.ts @@ -0,0 +1,90 @@ +import type { + AuthAccessSnapshot, + AuthAccessStreamEvent, + AuthAccessStreamSnapshotEvent, +} from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe } from "../rpc/client.ts"; +import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; + +export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { + pairingLinks: [], + clientSessions: [], +}; + +function upsertByKey( + values: ReadonlyArray, + next: A, + key: (value: A) => string, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function applyAuthAccessStreamEvent( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): AuthAccessSnapshot { + switch (event.type) { + case "snapshot": + return event.payload; + case "pairingLinkUpserted": + return { + ...current, + pairingLinks: upsertByKey(current.pairingLinks, event.payload, (value) => value.id), + }; + case "pairingLinkRemoved": + return { + ...current, + pairingLinks: current.pairingLinks.filter((value) => value.id !== event.payload.id), + }; + case "clientUpserted": + return { + ...current, + clientSessions: upsertByKey( + current.clientSessions, + event.payload, + (value) => value.sessionId, + ), + }; + case "clientRemoved": + return { + ...current, + clientSessions: current.clientSessions.filter( + (value) => value.sessionId !== event.payload.sessionId, + ), + }; + } +} + +export function projectAuthAccessSnapshot( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): readonly [AuthAccessSnapshot, ReadonlyArray] { + const snapshot = applyAuthAccessStreamEvent(current, event); + const projected: AuthAccessStreamSnapshotEvent = { + version: 1, + revision: event.revision, + type: "snapshot", + payload: snapshot, + }; + return [snapshot, [projected]]; +} + +export function createAuthEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:server:auth-access-changes", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeAuthAccess, {}).pipe( + Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), + ), + }), + }; +} diff --git a/packages/client-runtime/src/state/checkpointDiff.ts b/packages/client-runtime/src/state/checkpointDiff.ts new file mode 100644 index 00000000000..455ceaf00d7 --- /dev/null +++ b/packages/client-runtime/src/state/checkpointDiff.ts @@ -0,0 +1,25 @@ +import type { + EnvironmentId, + OrchestrationGetFullThreadDiffResult, + OrchestrationGetTurnDiffResult, + ThreadId, +} from "@t3tools/contracts"; + +export type CheckpointDiffResult = + | OrchestrationGetTurnDiffResult + | OrchestrationGetFullThreadDiffResult; + +export interface CheckpointDiffState { + readonly data: CheckpointDiffResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; + readonly cacheScope?: string | null; +} diff --git a/packages/client-runtime/src/state/composerPathSearch.ts b/packages/client-runtime/src/state/composerPathSearch.ts new file mode 100644 index 00000000000..262c9f49b5f --- /dev/null +++ b/packages/client-runtime/src/state/composerPathSearch.ts @@ -0,0 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface ComposerPathSearchEntry { + readonly path: string; + readonly kind: "file" | "directory"; + readonly parentPath?: string; +} + +export interface ComposerPathSearchState { + readonly entries: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts new file mode 100644 index 00000000000..6f406b409a2 --- /dev/null +++ b/packages/client-runtime/src/state/connections.ts @@ -0,0 +1,120 @@ +import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; +import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { + createAtomCommandScheduler, + createRuntimeCommand, + followStreamInEnvironment, +} from "./runtime.ts"; + +export interface EnvironmentCatalogState { + readonly isReady: boolean; + readonly entries: ReadonlyMap; +} + +export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ + isReady: false, + entries: new Map(), +}); + +export function createEnvironmentCatalogAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + const serial = { mode: "serial" as const, key: () => "environment-catalog" }; + const catalogAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => + SubscriptionRef.changes(registry.entries).pipe( + Stream.map((entries) => ({ + isReady: true, + entries, + })), + ), + ), + ), + ), + { initialValue: EMPTY_ENVIRONMENT_CATALOG_STATE }, + ); + + const catalogValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), + ).pipe(Atom.withLabel("environment-catalog-value")); + + const networkStatusAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), + ), + ), + { initialValue: "unknown" as const }, + ); + + const networkStatusValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), + ).pipe(Atom.withLabel("environment-network-status-value")); + + const stateAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ), + { initialValue: AVAILABLE_CONNECTION_STATE }, + ), + ); + + const register = createRuntimeCommand(runtime, { + label: "environment-catalog:register", + scheduler: commandScheduler, + concurrency: serial, + execute: (target: Parameters[0]) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), + }); + const remove = createRuntimeCommand(runtime, { + label: "environment-catalog:remove", + scheduler: commandScheduler, + concurrency: serial, + execute: (environmentId: EnvironmentIdType) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), + }); + const removeRelayEnvironments = createRuntimeCommand(runtime, { + label: "environment-catalog:remove-relay-environments", + scheduler: commandScheduler, + concurrency: serial, + execute: (_input: void) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), + }); + const retryNow = createRuntimeCommand(runtime, { + label: "environment-catalog:retry-now", + scheduler: commandScheduler, + concurrency: serial, + execute: (environmentId: EnvironmentIdType) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), + }); + + return { + catalogAtom, + catalogValueAtom, + networkStatusAtom, + networkStatusValueAtom, + stateAtom, + register, + remove, + removeRelayEnvironments, + retryNow, + }; +} diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts new file mode 100644 index 00000000000..2bdb8f84250 --- /dev/null +++ b/packages/client-runtime/src/state/entities.test.ts @@ -0,0 +1,310 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationShellSnapshot, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; +import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; +import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; +import { mergeEnvironmentThread } from "./threadDetail.ts"; +import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const OTHER_PROJECT_ID = ProjectId.make("project-2"); +const THREAD_ID = ThreadId.make("thread-1"); +const OTHER_THREAD_ID = ThreadId.make("thread-2"); + +const THREAD_SHELL = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +} as const; + +const SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + updatedAt: "2026-06-01T00:00:00.000Z", + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + { + id: OTHER_PROJECT_ID, + title: "Other project", + workspaceRoot: "/other-repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + threads: [ + THREAD_SHELL, + { + ...THREAD_SHELL, + id: OTHER_THREAD_ID, + projectId: OTHER_PROJECT_ID, + title: "Other thread", + }, + ], +}; + +function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState { + return { + snapshot: Option.some(snapshot), + status: "live", + error: Option.none(), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(AsyncResult.success(shellState(SNAPSHOT))), + ); + const threadStateAtoms = Atom.family((_key: string) => + Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ + ENVIRONMENT_ID, + { + target: new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Environment", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + }, + ], + ]), + }); + const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); + const projects = createEnvironmentProjectAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => + threadStateAtoms(`${environmentId}\u0000${threadId}`), + ); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), + threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), + projects, + threadShells, + threadDetails, + }; +} + +describe("environment entity projections", () => { + it("composes detail collections with authoritative shell workspace metadata", () => { + const messages: OrchestrationThread["messages"] = []; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + title: "Cached thread", + branch: "stale-branch", + worktreePath: "/repo/stale-worktree", + deletedAt: null, + messages, + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + title: "Current thread", + branch: "current-branch", + worktreePath: "/repo/current-worktree", + }; + + const merged = mergeEnvironmentThread(detail, shell); + + expect(merged).toMatchObject({ + title: "Current thread", + branch: "current-branch", + worktreePath: "/repo/current-worktree", + }); + expect(merged?.messages).toBe(messages); + }); + + it("preserves untouched project and thread identities across unrelated shell updates", () => { + const harness = makeHarness(); + const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); + const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID); + const projectsAtom = harness.projects.projectsAtom; + const projectAtom = harness.projects.projectAtom({ + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }); + const threadAtom = harness.threadShells.threadShellAtom({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }); + const projectRefs = harness.registry.get(projectRefsAtom); + const threadRefs = harness.registry.get(threadRefsAtom); + const projects = harness.registry.get(projectsAtom); + const project = harness.registry.get(projectAtom); + const thread = harness.registry.get(threadAtom); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((candidate) => + candidate.id === OTHER_THREAD_ID + ? { ...candidate, title: "Renamed other thread" } + : candidate, + ), + }), + ), + ); + + expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs); + expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs); + expect(harness.registry.get(projectsAtom)).toBe(projects); + expect(harness.registry.get(projectAtom)).toBe(project); + expect(harness.registry.get(threadAtom)).toBe(thread); + }); + + it("preserves project-scoped thread collections across unrelated project updates", () => { + const harness = makeHarness(); + const projectRef = { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }; + const refsByProjectAtom = + harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); + const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); + const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); + const threads = harness.registry.get(threadsAtom); + + expect(threads).toHaveLength(1); + expect(threads[0]?.id).toBe(THREAD_ID); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((thread) => + thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread, + ), + }), + ), + ); + + expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); + expect(harness.registry.get(threadsAtom)).toBe(threads); + }); + + it("updates only the requested thread detail and preserves untouched field identities", () => { + const harness = makeHarness(); + const threadRef = { + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }; + const otherThreadRef = { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }; + const threadDetailAtom = harness.threadDetails.detailAtom(threadRef); + const messagesAtom = harness.threadDetails.messagesAtom(threadRef); + const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef); + const statusAtom = harness.threadDetails.statusAtom(threadRef); + const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef); + const otherValue = harness.registry.get(otherThreadDetailAtom); + const detail = { + ...THREAD_SHELL, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread; + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some(detail), + status: "live", + error: Option.none(), + }), + ); + + const scopedDetail = harness.registry.get(threadDetailAtom); + const messages = harness.registry.get(messagesAtom); + const activities = harness.registry.get(activitiesAtom); + + expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID }); + expect(harness.registry.get(statusAtom)).toBe("live"); + expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some({ + ...detail, + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-06-01T00:01:00.000Z", + }, + }), + status: "live", + error: Option.none(), + }), + ); + + expect(harness.registry.get(messagesAtom)).toBe(messages); + expect(harness.registry.get(activitiesAtom)).toBe(activities); + }); +}); diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts new file mode 100644 index 00000000000..4bcf16f7cfd --- /dev/null +++ b/packages/client-runtime/src/state/entities.ts @@ -0,0 +1,81 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type ScopedProjectRef, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +export function projectKey(ref: ScopedProjectRef): string { + return `${ref.environmentId}\u0000${ref.projectId}`; +} + +export function threadKey(ref: ScopedThreadRef): string { + return `${ref.environmentId}\u0000${ref.threadId}`; +} + +export function projectRefCollectionKey(refs: ReadonlyArray): string { + return JSON.stringify(refs.map((ref) => [ref.environmentId, ref.projectId])); +} + +export function parseProjectKey(key: string): ScopedProjectRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid scoped project atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + projectId: ProjectId.make(key.slice(separator + 1)), + }; +} + +export function parseProjectRefCollectionKey(key: string): ReadonlyArray { + const entries = JSON.parse(key) as ReadonlyArray; + return entries.map(([environmentId, projectId]) => ({ + environmentId: EnvironmentId.make(environmentId), + projectId: ProjectId.make(projectId), + })); +} + +export function parseThreadKey(key: string): ScopedThreadRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid scoped thread atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + threadId: ThreadId.make(key.slice(separator + 1)), + }; +} + +export function projectRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +export function threadRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.threadId === right[index]?.threadId, + ) + ); +} + +export function arrayElementsEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts new file mode 100644 index 00000000000..c78b66cf316 --- /dev/null +++ b/packages/client-runtime/src/state/filesystem.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createFilesystemEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + browse: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:filesystem:browse", + tag: WS_METHODS.filesystemBrowse, + }), + }; +} diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts new file mode 100644 index 00000000000..8a743485b4f --- /dev/null +++ b/packages/client-runtime/src/state/git.ts @@ -0,0 +1,23 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createGitEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:git:resolve-pull-request", + tag: WS_METHODS.gitResolvePullRequest, + }), + preparePullRequestThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:git:prepare-pull-request-thread", + tag: WS_METHODS.gitPreparePullRequestThread, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} diff --git a/packages/client-runtime/src/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts similarity index 100% rename from packages/client-runtime/src/gitActions.ts rename to packages/client-runtime/src/state/gitActions.ts diff --git a/packages/client-runtime/src/shellTypes.ts b/packages/client-runtime/src/state/models.ts similarity index 52% rename from packages/client-runtime/src/shellTypes.ts rename to packages/client-runtime/src/state/models.ts index 1d3a6e35de2..b601b59bfad 100644 --- a/packages/client-runtime/src/shellTypes.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,38 +1,53 @@ import type { EnvironmentId, + OrchestrationMessage, OrchestrationProjectShell, OrchestrationShellSnapshot, + OrchestrationThread, OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; -export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell { +export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; } -export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell { +export interface EnvironmentThreadShell extends OrchestrationThreadShell { readonly environmentId: EnvironmentId; } -export function scopeProjectShell( +export type EnvironmentMessage = OrchestrationMessage; + +export interface EnvironmentThread extends OrchestrationThread { + readonly environmentId: EnvironmentId; +} + +export function scopeProject( environmentId: EnvironmentId, project: OrchestrationProjectShell, -): EnvironmentScopedProjectShell { +): EnvironmentProject { return { ...project, environmentId }; } export function scopeThreadShell( environmentId: EnvironmentId, thread: OrchestrationThreadShell, -): EnvironmentScopedThreadShell { +): EnvironmentThreadShell { + return { ...thread, environmentId }; +} + +export function scopeThread( + environmentId: EnvironmentId, + thread: OrchestrationThread, +): EnvironmentThread { return { ...thread, environmentId }; } -export function selectScopedThreadShell( +export function selectEnvironmentThreadShell( snapshot: OrchestrationShellSnapshot | null, environmentId: EnvironmentId, threadId: ThreadId, -): EnvironmentScopedThreadShell | null { +): EnvironmentThreadShell | null { const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; return thread ? scopeThreadShell(environmentId, thread) : null; } diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts new file mode 100644 index 00000000000..f8faa49ea38 --- /dev/null +++ b/packages/client-runtime/src/state/orchestration.ts @@ -0,0 +1,24 @@ +import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createOrchestrationEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:turn-diff", + tag: ORCHESTRATION_WS_METHODS.getTurnDiff, + }), + fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:full-thread-diff", + tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, + }), + archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:archived-shell-snapshot", + tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + }), + }; +} diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts new file mode 100644 index 00000000000..1321ece93f8 --- /dev/null +++ b/packages/client-runtime/src/state/presentation.ts @@ -0,0 +1,69 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; +import { + presentEnvironmentConnection, + type EnvironmentPresentation, +} from "../connection/presentation.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentPresentationAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly stateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + const presentationAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const entry = get(input.catalogValueAtom).entries.get(environmentId); + if (entry === undefined) { + return null; + } + const state = Option.getOrElse( + AsyncResult.value(get(input.stateAtom(environmentId))), + () => AVAILABLE_CONNECTION_STATE, + ); + return { + entry, + connection: presentEnvironmentConnection(state), + serverConfig: get(input.configValueAtom(environmentId)), + } satisfies EnvironmentPresentation; + }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), + ); + + let previous: ReadonlyMap = new Map(); + const presentationsAtom = Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const presentation = get(presentationAtom(environmentId)); + if (presentation !== null) { + next.set(environmentId, presentation); + } + } + if (mapsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel("environment-presentations")); + + return { + presentationAtom, + presentationsAtom, + }; +} diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts new file mode 100644 index 00000000000..1c923205710 --- /dev/null +++ b/packages/client-runtime/src/state/preview.ts @@ -0,0 +1,103 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +export function createPreviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const lifecycleScheduler = createAtomCommandScheduler(); + const statusScheduler = createAtomCommandScheduler(); + const automationScheduler = createAtomCommandScheduler(); + const lifecycleConcurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => + JSON.stringify([environmentId, input.threadId]), + }; + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:preview:list", + tag: WS_METHODS.previewList, + staleTimeMs: 5_000, + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:events", + tag: WS_METHODS.subscribePreviewEvents, + }), + discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:discovered-servers", + tag: WS_METHODS.subscribeDiscoveredLocalServers, + }), + automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:automation-requests", + tag: WS_METHODS.previewAutomationConnect, + }), + open: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:open", + tag: WS_METHODS.previewOpen, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + navigate: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:navigate", + tag: WS_METHODS.previewNavigate, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + refresh: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:refresh", + tag: WS_METHODS.previewRefresh, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + close: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:close", + tag: WS_METHODS.previewClose, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + reportStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:report-status", + tag: WS_METHODS.previewReportStatus, + scheduler: statusScheduler, + concurrency: { + mode: "latest", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.tabId]), + }, + }), + respondToAutomation: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-respond", + tag: WS_METHODS.previewAutomationRespond, + scheduler: automationScheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]), + }, + }), + reportAutomationOwner: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-report-owner", + tag: WS_METHODS.previewAutomationReportOwner, + scheduler: automationScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), + }, + }), + clearAutomationOwner: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:automation-clear-owner", + tag: WS_METHODS.previewAutomationClearOwner, + scheduler: automationScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]), + }, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts new file mode 100644 index 00000000000..3defcc32154 --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -0,0 +1,106 @@ +import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentCommand, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import { + type CreateProjectInput, + type DeleteProjectInput, + type UpdateProjectInput, + createProject, + deleteProject, + updateProject, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + CreateProjectInput, + DeleteProjectInput, + UpdateProjectInput, +} from "../operations/commands.ts"; + +export interface OptimisticProjectFile { + readonly data: ProjectReadFileResult; + readonly confirmedAgainst: object | null | undefined; +} + +export interface OptimisticProjectFileTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; +} + +function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { + return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); +} + +export function createProjectEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const projectScheduler = createAtomCommandScheduler(); + const fileScheduler = createAtomCommandScheduler(); + const optimisticFileFamily = Atom.family((key: string) => + Atom.make(null).pipe( + Atom.withLabel(`environment-data:projects:optimistic-file:${key}`), + ), + ); + const projectConcurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) => + JSON.stringify([environmentId, input.projectId]), + }; + return { + searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:search-entries", + tag: WS_METHODS.projectsSearchEntries, + staleTimeMs: 15_000, + }), + listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:list-entries", + tag: WS_METHODS.projectsListEntries, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + readFile: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:read-file", + tag: WS_METHODS.projectsReadFile, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + optimisticFile: (target: OptimisticProjectFileTarget) => + optimisticFileFamily(optimisticProjectFileKey(target)), + create: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:create", + execute: (input: CreateProjectInput) => createProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + update: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:update", + execute: (input: UpdateProjectInput) => updateProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + delete: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:delete", + execute: (input: DeleteProjectInput) => deleteProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), + writeFile: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:write-file", + tag: WS_METHODS.projectsWriteFile, + scheduler: fileScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.cwd, input.relativePath]), + }, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts new file mode 100644 index 00000000000..4d51b4d427e --- /dev/null +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -0,0 +1,105 @@ +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentProject } from "./models.ts"; +import { scopeProject } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; + +const EMPTY_PROJECTS: ReadonlyArray = Object.freeze([]); +const EMPTY_PROJECT_INDEX: ReadonlyMap = new Map(); + +export function createEnvironmentProjectAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.projects ?? EMPTY_PROJECTS, + ).pipe(Atom.withLabel(`environment-projects:${environmentId}`)), + ); + + const environmentProjectIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const projects = get(environmentProjectsAtom(environmentId)); + if (projects.length === 0) { + return EMPTY_PROJECT_INDEX; + } + return new Map(projects.map((project) => [project.id, project] as const)); + }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)), + ); + + const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentProjectsAtom(environmentId)).map((project) => ({ + environmentId, + projectId: project.id, + })); + if (projectRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`)); + }); + + const projectAtomFamily = Atom.family((key: string) => { + const ref = parseProjectKey(key); + let previousSource: OrchestrationProjectShell | null = null; + let previousValue: EnvironmentProject | null = null; + return Atom.make((get) => { + const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeProject(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-project:${key}`)); + }); + + let previousProjectRefs: ReadonlyArray = []; + const projectRefsAtom = Atom.make((get) => { + const refs: ScopedProjectRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentProjectRefsAtom(environmentId))); + } + if (projectRefsEqual(previousProjectRefs, refs)) { + return previousProjectRefs; + } + previousProjectRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-project-refs")); + + let previousProjects: ReadonlyArray = []; + const projectsAtom = Atom.make((get) => { + const next = get(projectRefsAtom).flatMap((ref) => { + const project = get(projectAtomFamily(projectKey(ref))); + return project === null ? [] : [project]; + }); + if (arrayElementsEqual(previousProjects, next)) { + return previousProjects; + } + previousProjects = next; + return previousProjects; + }).pipe(Atom.withLabel("environment-project-list")); + + return { + environmentProjectsAtom, + environmentProjectIndexAtom, + environmentProjectRefsAtom, + projectRefsAtom, + projectsAtom, + projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), + }; +} diff --git a/packages/client-runtime/src/projectPaths.ts b/packages/client-runtime/src/state/projects.ts similarity index 98% rename from packages/client-runtime/src/projectPaths.ts rename to packages/client-runtime/src/state/projects.ts index a4d2c7e19ee..82a43350650 100644 --- a/packages/client-runtime/src/projectPaths.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -5,9 +5,9 @@ import { isWindowsDrivePath, } from "@t3tools/shared/path"; -function isWindowsPlatform(platform: string): boolean { +const isWindowsPlatform = (platform: string): boolean => { return /^win(dows)?/i.test(platform); -} +}; function isRootPath(value: string): boolean { return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); @@ -219,3 +219,6 @@ export function getBrowseParentPath(currentPath: string): string | null { export function canNavigateUp(currentPath: string): boolean { return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; } + +export * from "./projectCommands.ts"; +export * from "./projectEntities.ts"; diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts new file mode 100644 index 00000000000..927671e176f --- /dev/null +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { + EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + RelayEnvironmentDiscovery, +} from "../relay/discovery.ts"; +import { createRuntimeCommand } from "./runtime.ts"; + +export function createRelayEnvironmentDiscoveryAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = runtime.atom( + Stream.unwrap( + RelayEnvironmentDiscovery.pipe( + Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), + ), + ), + { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, + ); + const stateValueAtom = Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(stateAtom)), + () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + ).pipe(Atom.withLabel("relay-environment-discovery-value")); + const refresh = createRuntimeCommand(runtime, { + label: "relay-environment-discovery:refresh", + concurrency: { mode: "singleFlight", key: () => "refresh" }, + execute: (_input: void) => + RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)), + }); + + return { + stateAtom, + stateValueAtom, + refresh, + }; +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts new file mode 100644 index 00000000000..0d78d6edd9f --- /dev/null +++ b/packages/client-runtime/src/state/review.ts @@ -0,0 +1,17 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createReviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:review:diff-preview", + tag: WS_METHODS.reviewGetDiffPreview, + staleTimeMs: 5_000, + }), + }; +} diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts new file mode 100644 index 00000000000..7584e55d52e --- /dev/null +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + environmentRpcKey, + createAtomCommandScheduler, + createRuntimeCommand, + executeAtomCommand, + executeAtomQuery, + isAtomCommandInterrupted, + mapAtomCommandResult, + runAtomCommand, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "./runtime.ts"; + +describe("settleAsyncResult", () => { + it("preserves successful values and typed failures", async () => { + const success = await settleAsyncResult(() => Promise.resolve(Exit.succeed("done"))); + expect(AsyncResult.isSuccess(success)).toBe(true); + if (AsyncResult.isSuccess(success)) { + expect(success.value).toBe("done"); + } + + const expectedFailure = new Error("request failed"); + const failure = await settleAsyncResult(() => Promise.resolve(Exit.fail(expectedFailure))); + expect(AsyncResult.isFailure(failure)).toBe(true); + if (AsyncResult.isFailure(failure)) { + expect(Cause.hasDies(failure.cause)).toBe(false); + expect(Cause.squash(failure.cause)).toBe(expectedFailure); + } + }); + + it("encodes thrown and rejected promises as defects", async () => { + const thrownDefect = new Error("thrown defect"); + const thrown = await settleAsyncResult(() => { + throw thrownDefect; + }); + expect(AsyncResult.isFailure(thrown)).toBe(true); + if (AsyncResult.isFailure(thrown)) { + expect(Cause.hasDies(thrown.cause)).toBe(true); + expect(Cause.squash(thrown.cause)).toBe(thrownDefect); + } + + const rejectedDefect = new Error("rejected defect"); + const rejected = await settleAsyncResult(() => Promise.reject(rejectedDefect)); + expect(AsyncResult.isFailure(rejected)).toBe(true); + if (AsyncResult.isFailure(rejected)) { + expect(Cause.hasDies(rejected.cause)).toBe(true); + expect(Cause.squash(rejected.cause)).toBe(rejectedDefect); + } + }); +}); + +describe("atom command result helpers", () => { + it("maps successful command values", () => { + const result = mapAtomCommandResult(AsyncResult.success(2), (value) => value * 3); + + expect(result._tag).toBe("Success"); + if (result._tag === "Success") { + expect(result.value).toBe(6); + } + }); + + it("preserves failures while mapping", () => { + const result = mapAtomCommandResult( + AsyncResult.failure(Cause.fail("nope")), + (value) => value * 3, + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBe("nope"); + } + }); + + it("distinguishes interruption from other failures", () => { + const interrupted = AsyncResult.failure(Cause.interrupt(1)); + const failed = AsyncResult.failure(Cause.fail("nope")); + + expect(isAtomCommandInterrupted(interrupted)).toBe(true); + expect(isAtomCommandInterrupted(failed)).toBe(false); + expect(squashAtomCommandFailure(failed)).toBe("nope"); + }); + + it("settles raw promise boundaries as successes or defects", async () => { + const success = await settlePromise(() => Promise.resolve("done")); + expect(success._tag).toBe("Success"); + + const defect = new Error("raw promise rejected"); + const failure = await settlePromise(() => Promise.reject(defect)); + expect(failure._tag).toBe("Failure"); + if (failure._tag === "Failure") { + expect(Cause.hasDies(failure.cause)).toBe(true); + expect(Cause.squash(failure.cause)).toBe(defect); + } + }); + + it("reports expected failures and defects through separate policies", async () => { + const warnings: string[] = []; + const errors: string[] = []; + const reporter = { + warn: (message: string) => { + warnings.push(message); + }, + error: (message: string) => { + errors.push(message); + }, + }; + + await executeAtomCommand(() => Promise.resolve(Exit.fail("nope")), { label: "save" }, reporter); + await executeAtomCommand( + () => Promise.resolve(Exit.fail("ignored")), + { label: "quiet save", reportFailure: false }, + reporter, + ); + await executeAtomCommand( + () => Promise.reject(new Error("defect")), + { label: "quiet save", reportFailure: false }, + reporter, + ); + await executeAtomCommand( + () => Promise.resolve(Exit.interrupt(1)), + { label: "interrupted" }, + reporter, + ); + + expect(warnings).toEqual(["[atom-command] save failed"]); + expect(errors).toEqual(["[atom-command] quiet save defected"]); + }); +}); + +describe("environmentRpcKey", () => { + it("isolates subscription state by environment and cwd", () => { + const environmentId = EnvironmentId.make("environment-1"); + const originalTarget = { + environmentId, + input: { cwd: "/repo/original" }, + }; + const nextTarget = { + environmentId, + input: { cwd: "/repo/next" }, + }; + + expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); + expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); + expect( + environmentRpcKey({ + environmentId: EnvironmentId.make("environment-2"), + input: originalTarget.input, + }), + ).not.toBe(environmentRpcKey(originalTarget)); + }); +}); + +describe("Atom.fn mutation semantics", () => { + it.effect("interrupts the previous invocation when the same mutation atom is written again", () => + Effect.gen(function* () { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const interrupted: string[] = []; + const mutation = Atom.fn((id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe( + Effect.as(id), + Effect.onInterrupt(() => + Effect.sync(() => { + interrupted.push(id); + }), + ), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, "first"); + registry.set(mutation, "second"); + yield* Effect.yieldNow; + + expect(interrupted).toEqual(["first"]); + + secondLatch.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }), + ).toBe("second"); + + unmount(); + registry.dispose(); + }), + ); + + it.effect("keeps stream mutations waiting until the final emitted value", () => + Effect.gen(function* () { + const completionLatch = Latch.makeUnsafe(); + const mutation = Atom.fn(() => + Stream.make("progress").pipe( + Stream.concat(Stream.fromEffect(completionLatch.await.pipe(Effect.as("done")))), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, undefined); + + const progress = registry.get(mutation); + expect(AsyncResult.isSuccess(progress)).toBe(true); + if (AsyncResult.isSuccess(progress)) { + expect(progress.value).toBe("progress"); + expect(progress.waiting).toBe(true); + } + + completionLatch.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }), + ).toBe("done"); + + unmount(); + registry.dispose(); + }), + ); + + it.effect( + "allows concurrent effects to finish but does not correlate results to individual writes", + () => + Effect.gen(function* () { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const mutation = Atom.fn( + (id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), + { concurrent: true }, + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(mutation); + + registry.set(mutation, "first"); + const firstResult = yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }).pipe(Effect.forkChild({ startImmediately: true })); + registry.set(mutation, "second"); + const secondResult = yield* AtomRegistry.getResult(registry, mutation, { + suspendOnWaiting: true, + }).pipe(Effect.forkChild({ startImmediately: true })); + + secondLatch.openUnsafe(); + yield* Effect.yieldNow; + + const stillWaiting = registry.get(mutation); + expect(stillWaiting.waiting).toBe(true); + + firstLatch.openUnsafe(); + + expect(yield* Fiber.join(firstResult)).toBe("first"); + expect(yield* Fiber.join(secondResult)).toBe("first"); + + unmount(); + registry.dispose(); + }), + ); +}); + +describe("executeAtomQuery", () => { + it("keeps concurrent query results correlated to their atoms", async () => { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const firstAtom = Atom.make(firstLatch.await.pipe(Effect.as("first"))); + const secondAtom = Atom.make(secondLatch.await.pipe(Effect.as("second"))); + const registry = AtomRegistry.make(); + + const firstResult = executeAtomQuery(registry, firstAtom); + const secondResult = executeAtomQuery(registry, secondAtom); + + secondLatch.openUnsafe(); + firstLatch.openUnsafe(); + + const [first, second] = await Promise.all([firstResult, secondResult]); + expect(first._tag).toBe("Success"); + expect(second._tag).toBe("Success"); + if (first._tag === "Success" && second._tag === "Success") { + expect(first.value).toBe("first"); + expect(second.value).toBe("second"); + } + + registry.dispose(); + }); +}); + +describe("runtime command runner", () => { + it("encodes custom command rejections as defects", async () => { + const defect = new Error("custom command rejected"); + const registry = AtomRegistry.make(); + const result = await runAtomCommand( + registry, + { + label: "test.rejected-command", + run: () => Promise.reject(defect), + }, + undefined, + { reportDefect: false }, + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasDies(result.cause)).toBe(true); + expect(Cause.squash(result.cause)).toBe(defect); + } + registry.dispose(); + }); + + it("settles generated command scheduler defects from direct callers", async () => { + const defect = new Error("invalid command key"); + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.invalid-key", + concurrency: { + mode: "serial", + key: () => { + throw defect; + }, + }, + execute: () => Effect.void, + }); + const registry = AtomRegistry.make(); + + const result = await command.run(registry, undefined); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasDies(result.cause)).toBe(true); + expect(Cause.squash(result.cause)).toBe(defect); + } + registry.dispose(); + }); + + it("correlates parallel invocation results", async () => { + const firstLatch = Latch.makeUnsafe(); + const secondLatch = Latch.makeUnsafe(); + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.parallel", + execute: (id: "first" | "second") => + (id === "first" ? firstLatch : secondLatch).await.pipe(Effect.as(id)), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, "first"); + const second = command.run(registry, "second"); + secondLatch.openUnsafe(); + firstLatch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: "first", waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: "second", waiting: false }); + registry.dispose(); + }); + + it("serializes commands that share a scheduler and lane", async () => { + const firstLatch = Latch.makeUnsafe(); + const events: string[] = []; + const runtime = Atom.runtime(Layer.empty); + const scheduler = createAtomCommandScheduler(); + const concurrency = { mode: "serial" as const, key: () => "shared" }; + const firstCommand = createRuntimeCommand(runtime, { + label: "test.first", + scheduler, + concurrency, + execute: () => + Effect.sync(() => events.push("first:start")).pipe( + Effect.andThen(firstLatch.await), + Effect.tap(() => Effect.sync(() => events.push("first:end"))), + ), + }); + const secondCommand = createRuntimeCommand(runtime, { + label: "test.second", + scheduler, + concurrency, + execute: () => Effect.sync(() => events.push("second:start")), + }); + const registry = AtomRegistry.make(); + + const first = firstCommand.run(registry, undefined); + const second = secondCommand.run(registry, undefined); + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + + firstLatch.openUnsafe(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second:start"]); + registry.dispose(); + }); + + it("deduplicates single-flight commands by key", async () => { + const latch = Latch.makeUnsafe(); + let executions = 0; + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.single-flight", + concurrency: { mode: "singleFlight", key: (key: string) => key }, + execute: () => + Effect.sync(() => executions++).pipe(Effect.andThen(latch.await), Effect.as("done")), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, "same"); + const second = command.run(registry, "same"); + latch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: "done", waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: "done", waiting: false }); + expect(executions).toBe(1); + registry.dispose(); + }); + + it("coalesces pending latest-value commands without interrupting the active call", async () => { + const firstLatch = Latch.makeUnsafe(); + const executed: number[] = []; + const runtime = Atom.runtime(Layer.empty); + const command = createRuntimeCommand(runtime, { + label: "test.latest", + concurrency: { mode: "latest", key: () => "shared" }, + execute: (value: number) => + Effect.sync(() => executed.push(value)).pipe( + Effect.andThen(value === 1 ? firstLatch.await : Effect.void), + Effect.as(value), + ), + }); + const registry = AtomRegistry.make(); + + const first = command.run(registry, 1); + await Promise.resolve(); + const second = command.run(registry, 2); + const third = command.run(registry, 3); + firstLatch.openUnsafe(); + + expect(await first).toMatchObject({ _tag: "Success", value: 1, waiting: false }); + expect(await second).toMatchObject({ _tag: "Success", value: 3, waiting: false }); + expect(await third).toMatchObject({ _tag: "Success", value: 3, waiting: false }); + expect(executed).toEqual([1, 3]); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts new file mode 100644 index 00000000000..7bfeb81f5db --- /dev/null +++ b/packages/client-runtime/src/state/runtime.ts @@ -0,0 +1,651 @@ +import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; +import { + type EnvironmentRpcInput, + type EnvironmentRpcStreamFailure, + type EnvironmentRpcStreamValue, + type EnvironmentStreamCommandRpcTag, + type EnvironmentSubscriptionRpcTag, + type EnvironmentUnaryRpcTag, + request, + runStream, + subscribe, +} from "../rpc/client.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; + +interface EnvironmentAtomOptions { + readonly label: string; + readonly execute: (input: Input) => Effect.Effect; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: Input; + }>; +} + +interface EnvironmentQueryAtomOptions extends EnvironmentAtomOptions< + Input, + A, + E, + R +> { + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + readonly refreshIntervalMs?: number; +} + +interface EnvironmentSubscriptionAtomOptions { + readonly label: string; + readonly subscribe: (input: Input) => Stream.Stream; + readonly idleTtlMs?: number; +} + +export type SettledAsyncResult = AsyncResult.Success | AsyncResult.Failure; + +export type AtomCommandResult = SettledAsyncResult; + +export type AtomCommandSuccess = R extends AtomCommandResult ? A : never; + +export type AtomCommandFailure = R extends AtomCommandResult ? E : never; + +export interface AtomCommandOptions { + readonly label?: string; + readonly reportFailure?: boolean; + readonly reportDefect?: boolean; +} + +export interface AtomCommandReporter { + readonly warn: (message: string, cause: Cause.Cause) => void; + readonly error: (message: string, cause: Cause.Cause) => void; +} + +export interface AtomCommand { + readonly label: string; + readonly run: (registry: AtomRegistry.AtomRegistry, input: W) => Promise>; +} + +export type AtomCommandConcurrency = + /** Every invocation runs independently. */ + | { readonly mode: "parallel" } + | { + /** + * `serial` preserves every invocation in FIFO order, `singleFlight` shares an active + * invocation, and `latest` coalesces queued invocations to the newest input. + */ + readonly mode: "serial" | "singleFlight" | "latest"; + readonly key: (input: W) => string; + }; + +interface AtomCommandSchedulerState { + readonly serial: Map>; + readonly singleFlight: Map>; + readonly latest: Map; +} + +interface AtomCommandLatestBatch { + execute: () => Promise>; + readonly resolve: Array<(result: AtomCommandResult) => void>; +} + +interface AtomCommandLatestLane { + running: boolean; + pending: AtomCommandLatestBatch | undefined; +} + +export interface AtomCommandScheduler { + readonly schedule: ( + registry: AtomRegistry.AtomRegistry, + concurrency: AtomCommandConcurrency, + input: W, + execute: () => Promise>, + ) => Promise>; +} + +async function settleAtomCommandResult( + execute: () => Promise>, +): Promise> { + try { + return await execute(); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export function createAtomCommandScheduler(): AtomCommandScheduler { + const registryStates = new WeakMap(); + + const stateFor = (registry: AtomRegistry.AtomRegistry): AtomCommandSchedulerState => { + const existing = registryStates.get(registry); + if (existing !== undefined) { + return existing; + } + const state: AtomCommandSchedulerState = { + serial: new Map(), + singleFlight: new Map(), + latest: new Map(), + }; + registryStates.set(registry, state); + return state; + }; + + return { + schedule: ( + registry: AtomRegistry.AtomRegistry, + concurrency: AtomCommandConcurrency, + input: W, + execute: () => Promise>, + ): Promise> => { + if (concurrency.mode === "parallel") { + return execute(); + } + + const key = concurrency.key(input); + const state = stateFor(registry); + if (concurrency.mode === "singleFlight") { + const existing = state.singleFlight.get(key) as + | Promise> + | undefined; + if (existing !== undefined) { + return existing; + } + const current = execute(); + state.singleFlight.set(key, current); + void current.then( + () => { + if (state.singleFlight.get(key) === current) { + state.singleFlight.delete(key); + } + }, + () => { + if (state.singleFlight.get(key) === current) { + state.singleFlight.delete(key); + } + }, + ); + return current; + } + + if (concurrency.mode === "serial") { + const previous = state.serial.get(key); + const current = previous === undefined ? execute() : previous.then(execute, execute); + state.serial.set(key, current); + void current.then( + () => { + if (state.serial.get(key) === current) { + state.serial.delete(key); + } + }, + () => { + if (state.serial.get(key) === current) { + state.serial.delete(key); + } + }, + ); + return current; + } + + let lane = state.latest.get(key); + if (lane === undefined) { + lane = { running: false, pending: undefined }; + state.latest.set(key, lane); + } + const activeLane = lane; + + const result = new Promise>((resolve) => { + if (activeLane.pending === undefined) { + activeLane.pending = { + execute: execute as () => Promise>, + resolve: [resolve as (result: AtomCommandResult) => void], + }; + return; + } + activeLane.pending.execute = execute as () => Promise>; + activeLane.pending.resolve.push( + resolve as (result: AtomCommandResult) => void, + ); + }); + + if (!activeLane.running) { + activeLane.running = true; + void (async () => { + while (activeLane.pending !== undefined) { + const batch = activeLane.pending; + activeLane.pending = undefined; + let batchResult: AtomCommandResult; + try { + batchResult = await batch.execute(); + } catch (defect) { + batchResult = AsyncResult.failure(Cause.die(defect)); + } + for (const resolve of batch.resolve) { + resolve(batchResult); + } + } + activeLane.running = false; + if (state.latest.get(key) === activeLane) { + state.latest.delete(key); + } + })(); + } + + return result; + }, + }; +} + +export async function runAtomCommand( + registry: AtomRegistry.AtomRegistry, + command: AtomCommand, + input: W, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const result = await settleAtomCommandResult(() => command.run(registry, input)); + reportAtomCommandResult(result, { ...options, label: options.label ?? command.label }, reporter); + return result; +} + +export function mapAtomCommandResult( + result: AtomCommandResult, + map: (value: A) => B, +): AtomCommandResult { + return result._tag === "Success" + ? AsyncResult.success(map(result.value)) + : AsyncResult.failure(result.cause); +} + +export function isAtomCommandInterrupted(result: AtomCommandResult): boolean { + return result._tag === "Failure" && Cause.hasInterruptsOnly(result.cause); +} + +export function squashAtomCommandFailure(result: { + readonly cause: Cause.Cause; +}): unknown { + return Cause.squash(result.cause); +} + +export async function settleAsyncResult( + execute: () => Promise>, +): Promise> { + try { + return AsyncResult.fromExit(await execute()); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export async function executeAtomCommand( + execute: () => Promise>, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const result = await settleAsyncResult(execute); + reportAtomCommandResult(result, options, reporter); + return result; +} + +export async function executeAtomQuery( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom>, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): Promise> { + const query = Effect.scoped( + Effect.gen(function* () { + yield* AtomRegistry.mount(registry, atom); + return yield* AtomRegistry.getResult(registry, atom, { + suspendOnWaiting: true, + }); + }), + ); + return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); +} + +export function createRuntimeCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Effect.Effect; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency; + }, +): AtomCommand { + const scheduler = options.scheduler ?? createAtomCommandScheduler(); + const concurrency = options.concurrency ?? { mode: "parallel" as const }; + return { + label: options.label, + run: (registry, input) => + settleAtomCommandResult(() => + scheduler.schedule(registry, concurrency, input, () => { + const atom = runtime + .atom(options.execute(input, registry)) + .pipe(Atom.withLabel(options.label)); + return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); + }), + ), + }; +} + +export function createRuntimeStreamCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Stream.Stream; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency; + }, +): AtomCommand { + const scheduler = options.scheduler ?? createAtomCommandScheduler(); + const concurrency = options.concurrency ?? { mode: "parallel" as const }; + return { + label: options.label, + run: (registry, input) => + settleAtomCommandResult(() => + scheduler.schedule(registry, concurrency, input, () => { + const atom = runtime + .atom(options.execute(input, registry)) + .pipe(Atom.withLabel(options.label)); + return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); + }), + ), + }; +} + +export function reportAtomCommandResult( + result: AtomCommandResult, + options: AtomCommandOptions = {}, + reporter: AtomCommandReporter = console, +): void { + if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { + return; + } + + const label = options.label ?? "atom command"; + if (Cause.hasDies(result.cause)) { + if (options.reportDefect ?? true) { + reporter.error(`[atom-command] ${label} defected`, result.cause); + } + } else if (options.reportFailure ?? true) { + reporter.warn(`[atom-command] ${label} failed`, result.cause); + } +} + +export async function settlePromise( + execute: () => Promise, +): Promise> { + try { + return AsyncResult.success(await execute()); + } catch (defect) { + return AsyncResult.failure(Cause.die(defect)); + } +} + +export function environmentRpcKey(target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}): string { + return JSON.stringify([target.environmentId, target.input]); +} + +function parseEnvironmentRpcKey(key: string): { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +} { + const decoded = JSON.parse(key) as [EnvironmentIdType, Input]; + return { + environmentId: EnvironmentId.make(decoded[0]), + input: decoded[1], + }; +} + +export function runInEnvironment( + environmentId: EnvironmentIdType, + effect: Effect.Effect, +): Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.run(environmentId, effect)), + ); +} + +export function runStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return Stream.unwrap( + EnvironmentRegistry.pipe(Effect.map((registry) => registry.runStream(environmentId, stream))), + ); +} + +export function followStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream> { + return Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => registry.followStream(environmentId, stream)), + ), + ); +} + +function createEnvironmentQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentQueryAtomOptions, +): (target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}) => Atom.Atom> { + const rpcGenerationAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.state).pipe( + Stream.filterMap((state) => + state.phase === "connected" ? Result.succeed(state.generation) : Result.failVoid, + ), + Stream.changes, + Stream.map((generation) => generation), + ), + ), + ), + ), + ), + { initialValue: null }, + ), + ); + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; + const queryAtom = runtime + .atom((get) => { + const generation = Option.getOrNull( + AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), + ); + if (generation === null) { + return Effect.never; + } + return runInEnvironment(target.environmentId, options.execute(target.input)); + }) + .pipe( + Atom.swr({ + staleTime: options.staleTimeMs ?? 30_000, + revalidateOnMount: true, + }), + Atom.setIdleTTL(idleTtlMs), + ); + return ( + options.refreshIntervalMs === undefined + ? queryAtom + : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)) + ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); + }); + return (target) => family(environmentRpcKey(target)); +} + +export function createEnvironmentSubscriptionAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentSubscriptionAtomOptions, +) { + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + return runtime + .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) + .pipe( + Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), + Atom.withLabel(`${options.label}:${key}`), + ); + }); + return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) => + family(environmentRpcKey(target)); +} + +export function createEnvironmentCommand( + runtime: Atom.AtomRuntime, + options: EnvironmentAtomOptions, +) { + return createRuntimeCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (target) => runInEnvironment(target.environmentId, options.execute(target.input)), + }); +} + +function createEnvironmentStreamCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: Input) => Stream.Stream; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: Input; + }>; + }, +) { + return createRuntimeStreamCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (target) => + runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( + Stream.withSpan(options.label), + ), + }); +} + +export function createEnvironmentRpcQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + readonly refreshIntervalMs?: number; + }, +) { + return createEnvironmentQueryAtomFamily(runtime, { + label: options.label, + ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + ...(options.refreshIntervalMs === undefined + ? {} + : { refreshIntervalMs: options.refreshIntervalMs }), + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcSubscriptionAtomFamily< + R, + ER, + TTag extends EnvironmentSubscriptionRpcTag, + B = EnvironmentRpcStreamValue, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly idleTtlMs?: number; + readonly transform?: ( + stream: Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor | R + >, + ) => Stream.Stream, EnvironmentSupervisor | R>; + }, +) { + return createEnvironmentSubscriptionAtomFamily(runtime, { + label: options.label, + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + subscribe: (input: EnvironmentRpcInput) => { + const stream = subscribe(options.tag, input); + return options.transform === undefined + ? (stream as Stream.Stream, EnvironmentSupervisor | R>) + : options.transform(stream); + }, + }); +} + +export function createEnvironmentRpcCommand( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }>; + }, +) { + return createEnvironmentCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcStreamCommand< + R, + ER, + TTag extends EnvironmentStreamCommandRpcTag, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly scheduler?: AtomCommandScheduler; + readonly concurrency?: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentIdType; + readonly input: EnvironmentRpcInput; + }>; + }, +) { + return createEnvironmentStreamCommand(runtime, { + label: options.label, + ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + execute: (input: EnvironmentRpcInput) => runStream(options.tag, input), + }); +} diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts new file mode 100644 index 00000000000..4b9564e031c --- /dev/null +++ b/packages/client-runtime/src/state/server.test.ts @@ -0,0 +1,54 @@ +import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; + +const CONFIG = { + availableEditors: [], + issues: [], + keybindings: {}, + keybindingsConfigPath: null, + observability: null, + providers: [], + settings: {}, +} as unknown as ServerConfig; + +describe("server state projection", () => { + it("applies every config category to the projected snapshot", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const settings = { ...CONFIG.settings }; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "settingsUpdated", + payload: { settings }, + }); + + const result = Option.getOrThrow(projected); + expect(result.config.settings).toBe(settings); + expect(result.latestEvent.type).toBe("settingsUpdated"); + }); + + it("retains welcome when a ready event follows in the same stream chunk", () => { + const welcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/repo", + projectName: "repo", + } as ServerLifecycleWelcomePayload; + const [afterWelcome] = projectServerWelcome(Option.none(), { + type: "welcome", + payload: welcome, + }); + const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + type: "ready", + payload: {}, + }); + + expect(Option.getOrThrow(afterReady)).toBe(welcome); + expect(emitted).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts new file mode 100644 index 00000000000..23bb7bff2a9 --- /dev/null +++ b/packages/client-runtime/src/state/server.ts @@ -0,0 +1,182 @@ +import { + type EnvironmentId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": + return Option.some({ + config: event.config, + latestEvent: event, + }); + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + })); + } +} + +export function projectServerConfig( + current: Option.Option, + event: ServerConfigStreamEvent, +): readonly [Option.Option, ReadonlyArray] { + const next = applyServerConfigProjection(current, event); + return [next, Option.toArray(next)]; +} + +export function projectServerWelcome( + current: Option.Option, + event: { + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }, +): readonly [ + Option.Option, + ReadonlyArray, +] { + if (event.type !== "welcome") { + return [current, []]; + } + const welcome = event.payload as ServerLifecycleWelcomePayload; + return [Option.some(welcome), [welcome]]; +} + +export function createServerEnvironmentAtoms( + runtime: Atom.AtomRuntime, + options: { + readonly initialConfigValueAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; + }, +) { + const configScheduler = createAtomCommandScheduler(); + const configConcurrency = { + mode: "serial" as const, + key: ({ environmentId }: { readonly environmentId: string }) => environmentId, + }; + const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:config-projection", + tag: WS_METHODS.subscribeServerConfig, + transform: (stream) => + stream.pipe(Stream.mapAccum(Option.none, projectServerConfig)), + }); + const emptyConfigAtom = Atom.make(null).pipe( + Atom.withLabel("environment-data:server:config:empty"), + ); + const configValueAtom = Atom.family((environmentId: EnvironmentId | null) => { + if (environmentId === null) { + return emptyConfigAtom; + } + return Atom.make((get): ServerConfig | null => { + const projection = Option.getOrNull( + AsyncResult.value(get(configProjection({ environmentId, input: {} }))), + ); + return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); + }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); + }); + + return { + configValueAtom, + traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:trace-diagnostics", + tag: WS_METHODS.serverGetTraceDiagnostics, + }), + processDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-diagnostics", + tag: WS_METHODS.serverGetProcessDiagnostics, + }), + processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-resource-history", + tag: WS_METHODS.serverGetProcessResourceHistory, + }), + configProjection, + welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:welcome", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe( + Stream.mapAccum(Option.none, projectServerWelcome), + ), + }), + refreshProviders: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:refresh-providers", + tag: WS_METHODS.serverRefreshProviders, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + updateProvider: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-provider", + tag: WS_METHODS.serverUpdateProvider, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + upsertKeybinding: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:upsert-keybinding", + tag: WS_METHODS.serverUpsertKeybinding, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + removeKeybinding: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:remove-keybinding", + tag: WS_METHODS.serverRemoveKeybinding, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + updateSettings: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-settings", + tag: WS_METHODS.serverUpdateSettings, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + signalProcess: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:signal-process", + tag: WS_METHODS.serverSignalProcess, + }), + }; +} diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 00000000000..fe1dcdbe3f2 --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { initialConfigOption } from "./session.ts"; + +class TestConfigError extends Schema.TaggedErrorClass()("TestConfigError", { + message: Schema.String, +}) {} + +describe("environment session state", () => { + it.effect("turns an initial config failure into an empty value", () => + Effect.gen(function* () { + const result = yield* initialConfigOption( + Effect.fail(new TestConfigError({ message: "temporary failure" })), + ); + expect(Option.isNone(result)).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts new file mode 100644 index 00000000000..97a637a9c5a --- /dev/null +++ b/packages/client-runtime/src/state/session.ts @@ -0,0 +1,88 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export function initialConfigOption( + initialConfig: Effect.Effect, +): Effect.Effect> { + return initialConfig.pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.logWarning("Could not load the initial environment configuration.", { + error, + }).pipe(Effect.as(Option.none())), + ), + ); +} + +export function createEnvironmentSessionAtoms( + runtime: Atom.AtomRuntime, +) { + const configAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.mapEffect( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (session) => initialConfigOption(session.initialConfig), + }), + ), + ), + ), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const configValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ServerConfig | null => + Option.getOrNull( + Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()), + ), + ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), + ); + + const preparedConnectionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.prepared)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => + Option.none(), + ), + ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), + ); + + return { + configAtom, + configValueAtom, + preparedConnectionAtom, + preparedConnectionValueAtom, + }; +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts new file mode 100644 index 00000000000..5ed4d504ce3 --- /dev/null +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -0,0 +1,123 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { makeEnvironmentShellState } from "./shell.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +describe("environment shell synchronization", () => { + it.effect("publishes live state before persistence and preserves it when ready", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisorService); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.never, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentCacheStore, cache), + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: LIVE_SHELL_SNAPSHOT, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + const state = yield* SubscriptionRef.get(shellState); + expect(state.status).toBe("live"); + expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + }), + ); +}); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts new file mode 100644 index 00000000000..fcde2ad7d80 --- /dev/null +++ b/packages/client-runtime/src/state/shell.test.ts @@ -0,0 +1,130 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); + +function environmentEntry(environmentId: EnvironmentId, label: string) { + return { + target: new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: `https://${environmentId}.example.test`, + wsBaseUrl: `wss://${environmentId}.example.test`, + }), + profile: Option.none(), + }; +} + +function shellState(input: { + readonly status: EnvironmentShellState["status"]; + readonly updatedAt?: string; + readonly error?: string; + readonly snapshotSequence?: number; +}): EnvironmentShellState { + return { + snapshot: + input.updatedAt === undefined + ? Option.none() + : Option.some({ + snapshotSequence: input.snapshotSequence ?? 1, + updatedAt: input.updatedAt, + projects: [], + threads: [], + }), + status: input.status, + error: input.error === undefined ? Option.none() : Option.some(input.error), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((environmentId: EnvironmentId) => + Atom.make( + environmentId === ENVIRONMENT_ID + ? shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + }) + : shellState({ + status: "synchronizing", + updatedAt: "2026-06-02T00:00:00.000Z", + error: "Retrying.", + }), + ), + ); + const configAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(null), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ENVIRONMENT_ID, environmentEntry(ENVIRONMENT_ID, "Environment")], + [OTHER_ENVIRONMENT_ID, environmentEntry(OTHER_ENVIRONMENT_ID, "Other environment")], + ]), + }); + const summaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom, + shellStateValueAtom: shellStateAtoms, + }); + const serverConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom, + configValueAtom: configAtoms, + }); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms, + configAtom: configAtoms, + summaryAtom, + serverConfigsAtom, + }; +} + +describe("environment shell projections", () => { + it("summarizes shell state and preserves identity when only irrelevant snapshot data changes", () => { + const harness = makeHarness(); + const summary = harness.registry.get(harness.summaryAtom); + + expect(summary).toEqual({ + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + hasLiveShell: false, + firstError: "Retrying.", + latestSnapshotUpdatedAt: "2026-06-02T00:00:00.000Z", + }); + + harness.registry.set( + harness.shellStateAtom(ENVIRONMENT_ID), + shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + snapshotSequence: 2, + }), + ); + + expect(harness.registry.get(harness.summaryAtom)).toBe(summary); + }); + + it("preserves server-config map identity until a config reference changes", () => { + const harness = makeHarness(); + const empty = harness.registry.get(harness.serverConfigsAtom); + const config = { cwd: "/repo" } as ServerConfig; + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + const withConfig = harness.registry.get(harness.serverConfigsAtom); + + expect(withConfig).not.toBe(empty); + expect(withConfig.get(ENVIRONMENT_ID)).toBe(config); + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); + }); +}); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts new file mode 100644 index 00000000000..a697a4e2a6b --- /dev/null +++ b/packages/client-runtime/src/state/shell.ts @@ -0,0 +1,314 @@ +import { + ORCHESTRATION_WS_METHODS, + type EnvironmentId, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, + type ServerConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; + +export interface EnvironmentShellState { + readonly snapshot: Option.Option; + readonly status: EnvironmentShellStatus; + readonly error: Option.Option; +} + +const EMPTY_SHELL_STATE: EnvironmentShellState = { + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}; + +function shellStatusForSnapshot( + snapshot: Option.Option, +): EnvironmentShellStatus { + return Option.isSome(snapshot) ? "cached" : "empty"; +} + +function formatShellError(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not synchronize environment data."; +} + +export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + snapshot: cachedSnapshot, + status: shellStatusForSnapshot(cachedSnapshot), + error: Option.none(), + }); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentShellState.persist")(function* ( + snapshot: OrchestrationShellSnapshot, + ) { + yield* cache.saveShell(environmentId, snapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist environment shell cache.").pipe( + Effect.annotateLogs({ + environmentId, + error: error.message, + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + Effect.forkScoped, + ); + + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + })); + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setStreamError = (error: unknown) => + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(formatShellError(error)), + })); + + const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( + item: OrchestrationShellStreamItem, + ) { + const current = yield* SubscriptionRef.get(state); + const nextSnapshot = + item.kind === "snapshot" + ? item.snapshot + : Option.match(current.snapshot, { + onNone: () => null, + onSome: (snapshot) => + item.sequence > snapshot.snapshotSequence + ? applyShellStreamEvent(snapshot, item) + : snapshot, + }); + if (nextSnapshot === null) { + return; + } + + yield* SubscriptionRef.set(state, { + snapshot: Option.some(nextSnapshot), + status: "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, nextSnapshot); + }); + + yield* subscribe( + ORCHESTRATION_WS_METHODS.subscribeShell, + {}, + { + onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), + }, + ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + return state; +}); + +export function shellStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), + ); +} + +export interface EnvironmentShellSummary { + readonly hasSnapshot: boolean; + readonly hasSynchronizingShell: boolean; + readonly hasCachedShell: boolean; + readonly hasLiveShell: boolean; + readonly firstError: string | null; + readonly latestSnapshotUpdatedAt: string | null; +} + +const EMPTY_ENVIRONMENT_SHELL_SUMMARY: EnvironmentShellSummary = Object.freeze({ + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}); + +const EMPTY_SERVER_CONFIGS: ReadonlyMap = new Map(); + +function shellSummariesEqual( + left: EnvironmentShellSummary, + right: EnvironmentShellSummary, +): boolean { + return ( + left.hasSnapshot === right.hasSnapshot && + left.hasSynchronizingShell === right.hasSynchronizingShell && + left.hasCachedShell === right.hasCachedShell && + left.hasLiveShell === right.hasLiveShell && + left.firstError === right.firstError && + left.latestSnapshotUpdatedAt === right.latestSnapshotUpdatedAt + ); +} + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentShellSummaryAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousSummary = EMPTY_ENVIRONMENT_SHELL_SUMMARY; + return Atom.make((get) => { + let hasSnapshot = false; + let hasSynchronizingShell = false; + let hasCachedShell = false; + let hasLiveShell = false; + let firstError: string | null = null; + let latestSnapshotUpdatedAt: string | null = null; + + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const state = get(input.shellStateValueAtom(environmentId)); + hasSynchronizingShell ||= state.status === "synchronizing"; + hasCachedShell ||= state.status === "cached"; + hasLiveShell ||= state.status === "live"; + if (firstError === null) { + firstError = Option.getOrNull(state.error); + } + if (Option.isNone(state.snapshot)) { + continue; + } + hasSnapshot = true; + const updatedAt = state.snapshot.value.updatedAt; + if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { + latestSnapshotUpdatedAt = updatedAt; + } + } + + const next: EnvironmentShellSummary = { + hasSnapshot, + hasSynchronizingShell, + hasCachedShell, + hasLiveShell, + firstError, + latestSnapshotUpdatedAt, + }; + if (shellSummariesEqual(previousSummary, next)) { + return previousSummary; + } + previousSummary = next; + return previousSummary; + }).pipe(Atom.withLabel("environment-shell-summary")); +} + +export function createEnvironmentServerConfigsAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousServerConfigs = EMPTY_SERVER_CONFIGS; + return Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const config = get(input.configValueAtom(environmentId)); + if (config !== null) { + next.set(environmentId, config); + } + } + if (mapsEqual(previousServerConfigs, next)) { + return previousServerConfigs; + } + previousServerConfigs = next; + return previousServerConfigs; + }).pipe(Atom.withLabel("environment-server-configs")); +} + +export function createEnvironmentShellAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom(shellStateChanges(environmentId), { + initialValue: EMPTY_SHELL_STATE, + }), + ); + + const stateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), + ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), + ); + + return { + stateAtom, + stateValueAtom, + }; +} + +export * from "./models.ts"; +export * from "./shellCommands.ts"; +export * from "./shellReducer.ts"; +export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellCommands.ts b/packages/client-runtime/src/state/shellCommands.ts new file mode 100644 index 00000000000..785bb83ed47 --- /dev/null +++ b/packages/client-runtime/src/state/shellCommands.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createShellEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + openInEditor: createEnvironmentRpcCommand(runtime, { + label: "environment-data:shell:open-in-editor", + tag: WS_METHODS.shellOpenInEditor, + }), + }; +} diff --git a/packages/client-runtime/src/shellSnapshotReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts similarity index 98% rename from packages/client-runtime/src/shellSnapshotReducer.test.ts rename to packages/client-runtime/src/state/shellReducer.test.ts index 69ae5e5d69f..4689c1408f7 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; const baseSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 0, diff --git a/packages/client-runtime/src/shellSnapshotReducer.ts b/packages/client-runtime/src/state/shellReducer.ts similarity index 95% rename from packages/client-runtime/src/shellSnapshotReducer.ts rename to packages/client-runtime/src/state/shellReducer.ts index a30eedb769b..71c8a6b0eb3 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -2,7 +2,7 @@ import * as Arr from "effect/Array"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; /** - * Apply a single shell stream event to an existing snapshot, returning a new + * Reduce a single shell stream event into an existing snapshot, returning a new * snapshot with the event's changes applied. This is a pure reducer that both * web and mobile can use to keep their local shell snapshot in sync. * diff --git a/packages/client-runtime/src/state/snapshots.ts b/packages/client-runtime/src/state/snapshots.ts new file mode 100644 index 00000000000..0000dcb12ce --- /dev/null +++ b/packages/client-runtime/src/state/snapshots.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentShellState } from "./shell.ts"; + +export function createEnvironmentSnapshotAtom( + shellStateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>, +) { + return Atom.family((environmentId: EnvironmentId) => + Atom.make((get): OrchestrationShellSnapshot | null => + Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { + onNone: () => null, + onSome: (state) => Option.getOrNull(state.snapshot), + }), + ).pipe(Atom.withLabel(`environment-snapshot:${environmentId}`)), + ); +} diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts new file mode 100644 index 00000000000..5bff2fa77e2 --- /dev/null +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -0,0 +1,41 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createSourceControlEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + return { + discovery: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:source-control-discovery", + tag: WS_METHODS.serverDiscoverSourceControl, + }), + repository: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repository", + tag: WS_METHODS.sourceControlLookupRepository, + }), + cloneRepository: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:clone-repository", + tag: WS_METHODS.sourceControlCloneRepository, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId }) => environmentId, + }, + }), + publishRepository: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:publish-repository", + tag: WS_METHODS.sourceControlPublishRepository, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts new file mode 100644 index 00000000000..028f7a8c660 --- /dev/null +++ b/packages/client-runtime/src/state/terminal.ts @@ -0,0 +1,95 @@ +import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + EMPTY_TERMINAL_BUFFER_STATE, +} from "./terminalSession.ts"; + +export function createTerminalEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const lifecycleScheduler = createAtomCommandScheduler(); + const resizeScheduler = createAtomCommandScheduler(); + const terminalThreadKey = ({ + environmentId, + input, + }: { + readonly environmentId: string; + readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; + }) => JSON.stringify([environmentId, input.threadId]); + const terminalSessionKey = ({ + environmentId, + input, + }: { + readonly environmentId: string; + readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; + }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]); + const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey }; + return { + attach: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:attach", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), + ), + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:events", + tag: WS_METHODS.subscribeTerminalEvents, + }), + metadata: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:metadata", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( + Stream.scan([] as ReadonlyArray, applyTerminalMetadataStreamEvent), + ), + }), + open: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:open", + tag: WS_METHODS.terminalOpen, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + write: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:write", + tag: WS_METHODS.terminalWrite, + }), + resize: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:resize", + tag: WS_METHODS.terminalResize, + scheduler: resizeScheduler, + concurrency: { mode: "latest", key: terminalSessionKey }, + }), + clear: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:clear", + tag: WS_METHODS.terminalClear, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + restart: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:restart", + tag: WS_METHODS.terminalRestart, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + close: createEnvironmentRpcCommand(runtime, { + label: "environment-data:terminal:close", + tag: WS_METHODS.terminalClose, + scheduler: lifecycleScheduler, + concurrency: lifecycleConcurrency, + }), + }; +} + +export * from "./terminalSession.ts"; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts new file mode 100644 index 00000000000..85c57592d11 --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; + +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + selectRunningSubprocessTerminalIds, +} from "./terminalSession.ts"; + +const TARGET = { + environmentId: EnvironmentId.make("env-local"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-1", +} as const; + +const BASE_SNAPSHOT: TerminalSessionSnapshot = { + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history: "hello", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-04-01T00:00:00.000Z", +}; + +describe("terminal session reducers", () => { + it("prefers live attach status over stale metadata after the attach stream starts", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "error", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + message: "Terminal disconnected.", + }); + + expect(combineTerminalSessionState(summary, attached)).toMatchObject({ + status: "error", + error: "Terminal disconnected.", + version: 1, + }); + }); + + it("uses metadata status before an attach stream has emitted", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + + expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe( + "running", + ); + }); + + it("does not treat an idle running shell as a running subprocess", () => { + const idleSession = { + target: TARGET, + state: { + ...combineTerminalSessionState(null, EMPTY_TERMINAL_BUFFER_STATE), + status: "running" as const, + hasRunningSubprocess: false, + }, + }; + const activeSession = { + target: { ...TARGET, terminalId: "term-2" }, + state: { + ...idleSession.state, + hasRunningSubprocess: true, + }, + }; + + expect(selectRunningSubprocessTerminalIds([idleSession, activeSession])).toEqual(["term-2"]); + }); + + it("reduces attach snapshots and output without an imperative session manager", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const output = applyTerminalAttachStreamEvent( + snapshot, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " world", + }, + 8, + ); + + expect(output).toMatchObject({ + buffer: "lo world", + status: "running", + error: null, + version: 2, + }); + }); + + it("reduces terminal metadata snapshots, upserts, and removals", () => { + const initial = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: BASE_SNAPSHOT.status, + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + }); + const updated = applyTerminalMetadataStreamEvent(initial, { + type: "upsert", + terminal: { + ...initial[0]!, + hasRunningSubprocess: true, + }, + }); + const removed = applyTerminalMetadataStreamEvent(updated, { + type: "remove", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + + expect(updated).toHaveLength(1); + expect(updated[0]?.hasRunningSubprocess).toBe(true); + expect(removed).toEqual([]); + }); + + it("caps retained output by UTF-8 byte length", () => { + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "🙂🙂", + }, + 4, + ); + + expect(state.buffer).toBe("🙂"); + }); +}); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts new file mode 100644 index 00000000000..ee444e36db4 --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -0,0 +1,194 @@ +import type { + EnvironmentId, + TerminalAttachStreamEvent, + TerminalMetadataStreamEvent, + TerminalSessionSnapshot, + TerminalSummary, + ThreadId, +} from "@t3tools/contracts"; + +export interface TerminalSessionState { + readonly summary: TerminalSummary | null; + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly hasRunningSubprocess: boolean; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface TerminalBufferState { + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface KnownTerminalSessionTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly terminalId: string; +} + +export interface KnownTerminalSession { + readonly target: KnownTerminalSessionTarget; + readonly state: TerminalSessionState; +} + +export function selectRunningSubprocessTerminalIds( + sessions: ReadonlyArray, +): ReadonlyArray { + return sessions + .filter((session) => session.state.hasRunningSubprocess) + .map((session) => session.target.terminalId); +} + +export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ + buffer: "", + status: "closed", + error: null, + updatedAt: null, + version: 0, +}); + +export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ + summary: null, + buffer: "", + status: "closed", + error: null, + hasRunningSubprocess: false, + updatedAt: null, + version: 0, +}); + +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { + if (maxBufferBytes <= 0) { + return ""; + } + + const encoded = textEncoder.encode(buffer); + if (encoded.byteLength <= maxBufferBytes) { + return buffer; + } + + let start = encoded.byteLength - maxBufferBytes; + while (start < encoded.length) { + const byte = encoded[start]; + if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { + break; + } + start += 1; + } + + return textDecoder.decode(encoded.subarray(start)); +} + +export function terminalBufferStateFromSnapshot( + snapshot: TerminalSessionSnapshot, + maxBufferBytes: number, +): TerminalBufferState { + return { + buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), + status: snapshot.status, + error: null, + updatedAt: snapshot.updatedAt, + version: 1, + }; +} + +function latestTimestamp(left: string | null, right: string | null): string | null { + if (left === null) return right; + if (right === null) return left; + return Date.parse(left) >= Date.parse(right) ? left : right; +} + +export function combineTerminalSessionState( + summary: TerminalSummary | null, + buffer: TerminalBufferState, +): TerminalSessionState { + return { + summary, + buffer: buffer.buffer, + status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), + error: buffer.error, + hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, + updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), + version: buffer.version, + }; +} + +export function applyTerminalAttachStreamEvent( + current: TerminalBufferState, + event: TerminalAttachStreamEvent, + maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, +): TerminalBufferState { + switch (event.type) { + case "snapshot": + case "restarted": + return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + case "output": + return { + ...current, + buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), + status: current.status === "closed" ? "running" : current.status, + error: null, + version: current.version + 1, + }; + case "cleared": + return { + ...current, + buffer: "", + error: null, + version: current.version + 1, + }; + case "exited": + return { + ...current, + status: "exited", + error: null, + version: current.version + 1, + }; + case "closed": + return { + ...current, + status: "closed", + error: null, + version: current.version + 1, + }; + case "error": + return { + ...current, + status: "error", + error: event.message, + version: current.version + 1, + }; + case "activity": + return current; + } +} + +export function applyTerminalMetadataStreamEvent( + current: ReadonlyArray, + event: TerminalMetadataStreamEvent, +): ReadonlyArray { + if (event.type === "snapshot") { + return event.terminals; + } + if (event.type === "remove") { + return current.filter( + (terminal) => + terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, + ); + } + const next = current.filter( + (terminal) => + terminal.threadId !== event.terminal.threadId || + terminal.terminalId !== event.terminal.terminalId, + ); + return [...next, event.terminal]; +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts new file mode 100644 index 00000000000..aab5110e9cf --- /dev/null +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -0,0 +1,140 @@ +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts"; +import { + type ArchiveThreadInput, + type CreateThreadInput, + type DeleteThreadInput, + type InterruptThreadTurnInput, + type RespondToThreadApprovalInput, + type RespondToThreadUserInputInput, + type RevertThreadCheckpointInput, + type SetThreadInteractionModeInput, + type SetThreadRuntimeModeInput, + type StartThreadTurnInput, + type StopThreadSessionInput, + type UnarchiveThreadInput, + type UpdateThreadMetadataInput, + archiveThread, + createThread, + deleteThread, + interruptThreadTurn, + respondToThreadApproval, + respondToThreadUserInput, + revertThreadCheckpoint, + setThreadInteractionMode, + setThreadRuntimeMode, + startThreadTurn, + stopThreadSession, + unarchiveThread, + updateThreadMetadata, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + ArchiveThreadInput, + CreateThreadInput, + DeleteThreadInput, + InterruptThreadTurnInput, + RespondToThreadApprovalInput, + RespondToThreadUserInputInput, + RevertThreadCheckpointInput, + SetThreadInteractionModeInput, + SetThreadRuntimeModeInput, + StartThreadTurnInput, + StopThreadSessionInput, + UnarchiveThreadInput, + UpdateThreadMetadataInput, +} from "../operations/commands.ts"; + +export function createThreadEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const scheduler = createAtomCommandScheduler(); + const concurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => + JSON.stringify([environmentId, input.threadId]), + }; + return { + create: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:create", + execute: (input: CreateThreadInput) => createThread(input), + scheduler, + concurrency, + }), + delete: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:delete", + execute: (input: DeleteThreadInput) => deleteThread(input), + scheduler, + concurrency, + }), + archive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:archive", + execute: (input: ArchiveThreadInput) => archiveThread(input), + scheduler, + concurrency, + }), + unarchive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unarchive", + execute: (input: UnarchiveThreadInput) => unarchiveThread(input), + scheduler, + concurrency, + }), + updateMetadata: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:update-metadata", + execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), + scheduler, + concurrency, + }), + setRuntimeMode: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-runtime-mode", + execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), + scheduler, + concurrency, + }), + setInteractionMode: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-interaction-mode", + execute: (input: SetThreadInteractionModeInput) => setThreadInteractionMode(input), + scheduler, + concurrency, + }), + startTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:start-turn", + execute: (input: StartThreadTurnInput) => startThreadTurn(input), + scheduler, + concurrency, + }), + interruptTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:interrupt-turn", + execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), + scheduler, + concurrency, + }), + respondToApproval: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:respond-to-approval", + execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), + scheduler, + concurrency, + }), + respondToUserInput: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:respond-to-user-input", + execute: (input: RespondToThreadUserInputInput) => respondToThreadUserInput(input), + scheduler, + concurrency, + }), + revertCheckpoint: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:revert-checkpoint", + execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), + scheduler, + concurrency, + }), + stopSession: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:stop-session", + execute: (input: StopThreadSessionInput) => stopThreadSession(input), + scheduler, + concurrency, + }), + }; +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts new file mode 100644 index 00000000000..20caf4a05af --- /dev/null +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -0,0 +1,185 @@ +import type { + OrchestrationCheckpointSummary, + OrchestrationLatestTurn, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThread, + OrchestrationThreadActivity, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; +import { scopeThread } from "./models.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { parseThreadKey, threadKey } from "./entities.ts"; + +const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); +const EMPTY_CHECKPOINTS: ReadonlyArray = Object.freeze([]); +const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000; + +/** + * Combine detail-only collections with the shell's authoritative thread metadata. + * + * Shell and detail subscriptions are intentionally independent. A cached detail can + * therefore briefly outlive a newer shell snapshot after reconnecting. Workspace + * consumers must use the shell branch/worktree/project fields so they do not target + * a stale checkout while retaining messages, activities, plans, and checkpoints + * from the detail subscription. + */ +export function mergeEnvironmentThread( + detail: EnvironmentThread | null, + shell: EnvironmentThreadShell | null, +): EnvironmentThread | null { + if (detail === null || shell === null) { + return detail; + } + if (detail.environmentId !== shell.environmentId || detail.id !== shell.id) { + return detail; + } + + return { + ...detail, + environmentId: shell.environmentId, + id: shell.id, + projectId: shell.projectId, + title: shell.title, + modelSelection: shell.modelSelection, + runtimeMode: shell.runtimeMode, + interactionMode: shell.interactionMode, + branch: shell.branch, + worktreePath: shell.worktreePath, + latestTurn: shell.latestTurn, + createdAt: shell.createdAt, + updatedAt: shell.updatedAt, + archivedAt: shell.archivedAt, + session: shell.session, + }; +} + +export function createEnvironmentThreadDetailAtoms( + threadStateAtom: ( + environmentId: ScopedThreadRef["environmentId"], + threadId: ScopedThreadRef["threadId"], + ) => Atom.Atom>, +) { + const threadStateValueAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ), + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-state-value:${key}`), + ); + }); + + const threadDetailAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThread | null = null; + let previousValue: EnvironmentThread | null = null; + return Atom.make((get) => { + const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThread(ref.environmentId, source); + return previousValue; + }).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-detail:${key}`), + ); + }); + + const threadStatusAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-status:${key}`), + ), + ); + + const threadErrorAtomFamily = Atom.family((key: string) => + Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-error:${key}`), + ), + ); + + const threadMessagesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-messages:${key}`), + ), + ); + + const threadActivitiesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-activities:${key}`), + ), + ); + + const threadProposedPlansAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-proposed-plans:${key}`), + ), + ); + + const threadCheckpointsAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-checkpoints:${key}`), + ), + ); + + const threadSessionAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-session:${key}`), + ), + ); + + const threadLatestTurnAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-latest-turn:${key}`), + ), + ); + + return { + stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), + detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), + statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), + errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), + messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), + activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), + proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), + checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), + sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), + latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/threadDetailReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts similarity index 93% rename from packages/client-runtime/src/threadDetailReducer.test.ts rename to packages/client-runtime/src/state/threadReducer.test.ts index f2af7284083..94eb1c65370 100644 --- a/packages/client-runtime/src/threadDetailReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -11,7 +11,7 @@ import { } from "@t3tools/contracts"; import type { OrchestrationThread } from "@t3tools/contracts"; -import { applyThreadDetailEvent } from "./threadDetailReducer.ts"; +import { applyThreadDetailEvent } from "./threadReducer.ts"; const baseEventFields = { eventId: EventId.make("event-1"), @@ -523,6 +523,49 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.activities[0]?.kind).toBe("file-edit"); } }); + + it("preserves the complete activity history when live events arrive", () => { + const existingActivities = Array.from({ length: 129 }, (_, index) => ({ + id: EventId.make(`activity-${index}`), + tone: "tool" as const, + kind: "command", + summary: `Ran command ${index}`, + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: index, + createdAt: "2026-04-01T11:00:00.000Z", + })); + const result = applyThreadDetailEvent( + { ...baseThread, activities: existingActivities }, + { + ...baseEventFields, + sequence: 130, + occurredAt: "2026-04-01T11:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-129"), + tone: "tool", + kind: "command", + summary: "Ran command 129", + payload: {}, + turnId: TurnId.make("turn-1"), + sequence: 129, + createdAt: "2026-04-01T11:01:00.000Z", + }, + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activities).toHaveLength(130); + expect(result.thread.activities[0]?.id).toBe("activity-0"); + } + }); }); describe("thread.turn-diff-completed", () => { diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/state/threadReducer.ts similarity index 95% rename from packages/client-runtime/src/threadDetailReducer.ts rename to packages/client-runtime/src/state/threadReducer.ts index 53bad5785b9..670540fee70 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -13,24 +13,6 @@ import type { TurnId, } from "@t3tools/contracts"; -/** - * Retention limits for collections within a thread. - * These prevent unbounded growth of in-memory thread state. - */ -export interface ThreadDetailRetentionLimits { - readonly maxMessages: number; - readonly maxProposedPlans: number; - readonly maxCheckpoints: number; - readonly maxActivities: number; -} - -export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = { - maxMessages: 512, - maxProposedPlans: 64, - maxCheckpoints: 256, - maxActivities: 128, -}; - export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } | { readonly kind: "deleted" } @@ -65,7 +47,6 @@ const activityOrder = O.combineAll([ export function applyThreadDetailEvent( thread: OrchestrationThread, event: OrchestrationEvent, - limits: ThreadDetailRetentionLimits = DEFAULT_THREAD_DETAIL_LIMITS, ): ThreadDetailReducerResult { switch (event.type) { // ── Project events (irrelevant to thread detail) ──────────────── @@ -231,8 +212,6 @@ export function applyThreadDetailEvent( }, ) : Arr.append(thread.messages, message); - const cappedMessages = Arr.takeRight(messages, limits.maxMessages); - // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn @@ -287,7 +266,7 @@ export function applyThreadDetailEvent( kind: "updated", thread: { ...thread, - messages: cappedMessages, + messages, checkpoints, latestTurn, updatedAt: event.occurredAt, @@ -369,7 +348,6 @@ export function applyThreadDetailEvent( Arr.filter((entry) => entry.id !== proposedPlan.id), Arr.append(proposedPlan), Arr.sort(proposedPlanOrder), - Arr.takeRight(limits.maxProposedPlans), ); return { @@ -401,7 +379,6 @@ export function applyThreadDetailEvent( Arr.filter((entry) => entry.turnId !== checkpoint.turnId), Arr.append(checkpoint), Arr.sort(checkpointOrder), - Arr.takeRight(limits.maxCheckpoints), ); // Mid-turn diff updates produce placeholder checkpoints; record the @@ -438,18 +415,13 @@ export function applyThreadDetailEvent( entry.checkpointTurnCount <= event.payload.turnCount, ), Arr.sort(checkpointOrder), - Arr.takeRight(limits.maxCheckpoints), ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = pipe( - retainMessagesAfterRevert(thread.messages, retainedTurnIds), - Arr.takeRight(limits.maxMessages), - ); + const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), - Arr.takeRight(limits.maxProposedPlans), ); const activities = pipe( thread.activities, @@ -490,7 +462,6 @@ export function applyThreadDetailEvent( Arr.filter((activity) => activity.id !== event.payload.activity.id), Arr.append(event.payload.activity), Arr.sort(activityOrder), - Arr.takeRight(limits.maxActivities), ); return { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts new file mode 100644 index 00000000000..65cee0427eb --- /dev/null +++ b/packages/client-runtime/src/state/threadShell.ts @@ -0,0 +1,186 @@ +import type { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThreadShell } from "./models.ts"; +import { scopeThreadShell } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { + arrayElementsEqual, + parseProjectRefCollectionKey, + parseThreadKey, + projectRefCollectionKey, + threadKey, + threadRefsEqual, +} from "./entities.ts"; + +const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); +const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); +const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< + ProjectId, + ReadonlyArray +> = new Map(); + +export function createEnvironmentThreadShellAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, + ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), + ); + + const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const threads = get(environmentThreadsAtom(environmentId)); + if (threads.length === 0) { + return EMPTY_THREAD_INDEX; + } + return new Map(threads.map((thread) => [thread.id, thread] as const)); + }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), + ); + + const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentThreadsAtom(environmentId)).map((thread) => ({ + environmentId, + threadId: thread.id, + })); + if (threadRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-thread-refs:${environmentId}`)); + }); + + const environmentThreadRefsByProjectAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyMap< + ProjectId, + ReadonlyArray + > = EMPTY_THREAD_REFS_BY_PROJECT; + return Atom.make((get) => { + const grouped = new Map(); + for (const thread of get(environmentThreadsAtom(environmentId))) { + const refs = grouped.get(thread.projectId); + const ref = { environmentId, threadId: thread.id }; + if (refs === undefined) { + grouped.set(thread.projectId, [ref]); + } else { + refs.push(ref); + } + } + if (grouped.size === 0) { + previous = EMPTY_THREAD_REFS_BY_PROJECT; + return previous; + } + const next = new Map>(); + for (const [projectId, refs] of grouped) { + const previousRefs = previous.get(projectId); + next.set( + projectId, + previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs, + ); + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`)); + }); + + const threadShellAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThreadShell | null = null; + let previousValue: EnvironmentThreadShell | null = null; + return Atom.make((get) => { + const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); + }); + + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { + const projectRefs = parseProjectRefCollectionKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next: EnvironmentThreadShell[] = []; + const seen = new Set(); + for (const projectRef of projectRefs) { + const refs = + get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get( + projectRef.projectId, + ) ?? EMPTY_SCOPED_THREAD_REFS; + for (const ref of refs) { + const key = threadKey(ref); + if (seen.has(key)) { + continue; + } + seen.add(key); + const thread = get(threadShellAtomFamily(key)); + if (thread !== null) { + next.push(thread); + } + } + } + if (arrayElementsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-shells-for-projects:${key}`)); + }); + + let previousThreadRefs: ReadonlyArray = []; + const threadRefsAtom = Atom.make((get) => { + const refs: ScopedThreadRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentThreadRefsAtom(environmentId))); + } + if (threadRefsEqual(previousThreadRefs, refs)) { + return previousThreadRefs; + } + previousThreadRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-thread-refs")); + + let previousThreadShells: ReadonlyArray = []; + const threadShellsAtom = Atom.make((get) => { + const next = get(threadRefsAtom).flatMap((ref) => { + const thread = get(threadShellAtomFamily(threadKey(ref))); + return thread === null ? [] : [thread]; + }); + if (arrayElementsEqual(previousThreadShells, next)) { + return previousThreadShells; + } + previousThreadShells = next; + return previousThreadShells; + }).pipe(Atom.withLabel("environment-thread-shell-list")); + + return { + environmentThreadsAtom, + environmentThreadIndexAtom, + environmentThreadRefsAtom, + environmentThreadRefsByProjectAtom, + threadRefsAtom, + threadShellsAtom, + threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => + threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), + threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts new file mode 100644 index 00000000000..eef2550e2e2 --- /dev/null +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -0,0 +1,406 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationThread, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + makeEnvironmentThreadState, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Cached thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; + +type TestThreadInput = OrchestrationThreadStreamItem | Error; + +function testSession(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +function awaitThreadState( + observed: Queue.Queue, + predicate: (state: EnvironmentThreadState) => boolean, +) { + return Queue.take(observed).pipe( + Effect.repeat({ + until: predicate, + }), + ); +} + +const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { + readonly cached?: OrchestrationThread; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); + const retryCount = yield* Ref.make(0); + const subscriptionCount = yield* Ref.make(0); + const savedThreads = yield* Ref.make>([]); + const removedThreads = yield* Ref.make>([]); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const streamFrom = (queue: Queue.Queue) => + Stream.fromQueue(queue).pipe( + Stream.mapEffect((input) => + input instanceof Error ? Effect.fail(input) : Effect.succeed(input), + ), + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: () => + Stream.unwrap( + Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( + Effect.map(() => streamFrom(inputs)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(testSession(client)), + ); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisorService); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: (_environmentId, threadId) => + Effect.succeed( + threadId === THREAD_ID && options?.cached !== undefined + ? Option.some(options.cached) + : Option.none(), + ), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: (_environmentId, threadId) => + Ref.update(removedThreads, (current) => [...current, threadId]), + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentCacheStore, cache), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => + Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), + ), + Effect.forkScoped, + ); + + return { + inputs, + observed, + latest, + retryCount, + subscriptionCount, + supervisorState, + supervisorSession, + savedThreads, + removedThreads, + replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), + }; +}); + +const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + thread, + }, +}); + +const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-title"), + sequence, + occurredAt: "2026-04-01T01:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:00:00.000Z", + }, + }, +}); + +const deleted = (): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-deleted"), + sequence: 3, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { + threadId: THREAD_ID, + deletedAt: "2026-04-01T02:00:00.000Z", + }, + }, +}); + +describe("EnvironmentThreads", () => { + it.effect("publishes cached data before a live snapshot arrives", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "cached" && Option.isSome(value.data), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.isNone(state.error)).toBe(true); + }), + ); + + it.effect("reduces live events and persists the latest thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title")); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title"); + }), + ); + + it.effect("ignores replayed thread events at or below the snapshot sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + }), + ); + + it.effect("removes cached data when the thread is deleted", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, deleted()); + + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "deleted", + ); + + expect(Option.isNone(state.data)).toBe(true); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + }), + ); + + it.effect("preserves data after a domain failure and resumes on a replacement session", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, new Error("stream failed")); + + const state = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.getOrThrow(state.error)).toBe("stream failed"); + expect(yield* Ref.get(harness.retryCount)).toBe(0); + + yield* harness.replaceSession; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + title: "Recovered thread", + }), + ); + const recovered = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Recovered thread", + ); + + expect(Option.isNone(recovered.error)).toBe(true); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + }), + ); + + it.effect("recovers from a transient domain failure without replacing the session", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, new Error("thread not found yet")); + + const failed = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + expect(Option.getOrThrow(failed.error)).toBe("thread not found yet"); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + + yield* TestClock.adjust("250 millis"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) { + break; + } + yield* Effect.yieldNow; + } + yield* Queue.offer( + harness.inputs, + snapshot({ + ...BASE_THREAD, + title: "Materialized thread", + }), + ); + + const recovered = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Materialized thread", + ); + + expect(Option.isNone(recovered.error)).toBe(true); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.retryCount)).toBe(0); + }), + ); + + it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + expect((yield* Ref.get(harness.latest)).status).toBe("live"); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts new file mode 100644 index 00000000000..44b137f937c --- /dev/null +++ b/packages/client-runtime/src/state/threads.ts @@ -0,0 +1,269 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ThreadId, + type EnvironmentId as EnvironmentIdType, + type OrchestrationThread, + type OrchestrationThreadStreamItem, + type ThreadId as ThreadIdType, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { applyThreadDetailEvent } from "./threadReducer.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; + +export interface EnvironmentThreadState { + readonly data: Option.Option; + readonly status: EnvironmentThreadStatus; + readonly error: Option.Option; +} + +export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { + data: Option.none(), + status: "empty", + error: Option.none(), +}; + +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { + return Option.isSome(data) ? "cached" : "empty"; +} + +function formatThreadError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not synchronize the thread."; +} + +export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( + threadId: ThreadIdType, +) { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cached = yield* cache.loadThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + data: cached, + status: statusWithoutLiveData(cached), + error: Option.none(), + }); + const lastSequence = yield* SubscriptionRef.make(0); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( + thread: OrchestrationThread, + ) { + yield* cache.saveThread(environmentId, thread).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + Effect.forkScoped, + ); + + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" || current.status === "deleted" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + })); + const setStreamError = (cause: Cause.Cause) => + SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + error: Option.some(formatThreadError(cause)), + })); + + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( + thread: OrchestrationThread, + ) { + yield* SubscriptionRef.set(state, { + data: Option.some(thread), + status: "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, thread); + }); + + const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { + yield* SubscriptionRef.set(state, { + data: Option.none(), + status: "deleted", + error: Option.none(), + }); + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, + ) { + if (item.kind === "snapshot") { + yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + yield* setThread(item.snapshot.thread); + return; + } + + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + return; + } + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(); + } + return; + } + const result = applyThreadDetailEvent(current.data.value, item.event); + if (result.kind === "updated") { + yield* setThread(result.thread); + } else if (result.kind === "deleted") { + yield* setDeleted(); + } + }); + + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + yield* setSynchronizing; + yield* subscribe( + ORCHESTRATION_WS_METHODS.subscribeThread, + { threadId }, + { + onExpectedFailure: setStreamError, + retryExpectedFailureAfter: "250 millis", + }, + ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(state).pipe( + Effect.flatMap((current) => + Option.match(current.data, { + onNone: () => Effect.void, + onSome: persist, + }), + ), + ), + ); + + return state; +}); + +export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), + ); +} + +function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string { + return `${environmentId}\u0000${threadId}`; +} + +function parseThreadAtomKey(key: string): { + readonly environmentId: EnvironmentIdType; + readonly threadId: ThreadIdType; +} { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid environment thread atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + threadId: ThreadId.make(key.slice(separator + 1)), + }; +} + +export function createEnvironmentThreadStateAtoms( + runtime: Atom.AtomRuntime, +) { + const family = Atom.family((key: string) => { + const { environmentId, threadId } = parseThreadAtomKey(key); + return runtime.atom(threadStateChanges(environmentId, threadId), { + initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, + }); + }); + + return { + stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => + family(threadAtomKey(environmentId, threadId)), + }; +} + +export * from "./archivedThreads.ts"; +export * from "./checkpointDiff.ts"; +export * from "./composerPathSearch.ts"; +export * from "./threadCommands.ts"; +export * from "./threadDetail.ts"; +export * from "./threadReducer.ts"; +export * from "./threadShell.ts"; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts new file mode 100644 index 00000000000..846d0d50609 --- /dev/null +++ b/packages/client-runtime/src/state/vcs.ts @@ -0,0 +1,85 @@ +import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export function createVcsEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-refs", + tag: WS_METHODS.vcsListRefs, + staleTimeMs: 5_000, + }), + status: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:status", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( + Stream.mapAccum( + () => null as VcsStatusResult | null, + (current, event) => { + const next = applyGitStatusStreamEvent(current, event); + return [next, [next]] as const; + }, + ), + ), + }), + pull: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:pull", + tag: WS_METHODS.vcsPull, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + refreshStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:refresh-status", + tag: WS_METHODS.vcsRefreshStatus, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + createWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:create-worktree", + tag: WS_METHODS.vcsCreateWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + removeWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:remove-worktree", + tag: WS_METHODS.vcsRemoveWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + createRef: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:create-ref", + tag: WS_METHODS.vcsCreateRef, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + switchRef: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:switch-ref", + tag: WS_METHODS.vcsSwitchRef, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + init: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:init", + tag: WS_METHODS.vcsInit, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + }; +} + +export * from "./gitActions.ts"; +export * from "./vcsAction.ts"; +export * from "./vcsRef.ts"; +export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts new file mode 100644 index 00000000000..b2ac9507319 --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -0,0 +1,342 @@ +import { + EnvironmentId, + type GitActionProgressEvent, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { AtomCommandResult } from "./runtime.ts"; +import { + applyVcsActionProgressEvent, + beginVcsActionState, + consumeVcsActionProgress, + createVcsActionManager, + createVcsActionTransportId, + EMPTY_VCS_ACTION_STATE, + getVcsActionTargetKey, + normalizeVcsActionProgressEvent, +} from "./vcsAction.ts"; + +const actionId = "action-123"; +const action = "commit_push" as const; +const cwd = "/repo"; +const environmentId = EnvironmentId.make("environment-1"); +const result: GitRunStackedActionResult = { + action, + branch: { + status: "skipped_not_requested", + }, + commit: { + status: "created", + commitSha: "abc123", + subject: "Test commit", + }, + push: { + status: "pushed", + branch: "feature", + }, + pr: { + status: "skipped_not_requested", + }, + toast: { + title: "Changes pushed", + cta: { + kind: "none", + }, + }, +}; + +function progress(event: T): T { + return event; +} + +describe("vcsActionState", () => { + it("projects phase and hook progress without owning the async operation", () => { + const initial = beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId, + }); + const phase = applyVcsActionProgressEvent( + initial, + progress({ + actionId, + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Committing...", + }), + ); + const hook = applyVcsActionProgressEvent( + phase, + progress({ + actionId, + action, + cwd, + kind: "hook_started", + hookName: "post-commit", + }), + ); + const output = applyVcsActionProgressEvent( + hook, + progress({ + actionId, + action, + cwd, + kind: "hook_output", + hookName: "post-commit", + stream: "stdout", + text: "hook output", + }), + ); + const finished = applyVcsActionProgressEvent( + output, + progress({ + actionId, + action, + cwd, + kind: "hook_finished", + hookName: "post-commit", + exitCode: 0, + durationMs: 12, + }), + ); + + expect(phase).toMatchObject({ + isRunning: true, + currentLabel: "Committing...", + currentPhaseLabel: "Committing...", + }); + expect(output).toMatchObject({ + currentLabel: "Running post-commit...", + hookName: "post-commit", + lastOutputLine: "hook output", + }); + expect(finished).toMatchObject({ + currentLabel: "Committing...", + hookName: null, + lastOutputLine: null, + }); + }); + + it("retains a terminal action error for presentation", () => { + const initial = beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId, + }); + const failed = applyVcsActionProgressEvent( + initial, + progress({ + actionId, + action, + cwd, + kind: "action_failed", + phase: null, + message: "Push failed.", + }), + ); + + expect(failed).toMatchObject({ + isRunning: false, + operation: "run_change_request", + actionId, + action, + error: "Push failed.", + }); + }); + + it("ignores progress after a newer action owns the target", () => { + const current = beginVcsActionState({ + operation: "pull", + label: "Pulling latest changes", + actionId: "newer-action", + }); + + expect( + applyVcsActionProgressEvent( + current, + progress({ + actionId, + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }), + ), + ).toBe(current); + }); + + it("keys presentation state only when the environment and repository are known", () => { + expect( + getVcsActionTargetKey({ + environmentId, + cwd, + }), + ).toBe(JSON.stringify([environmentId, cwd])); + expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); + expect( + getVcsActionTargetKey({ + environmentId, + cwd: null, + }), + ).toBeNull(); + }); + + it("normalizes progress only for the matching environment-scoped action", () => { + const target = { environmentId, cwd }; + const otherTarget = { + environmentId: EnvironmentId.make("environment-2"), + cwd, + }; + const transportActionId = createVcsActionTransportId(target, actionId); + const event = progress({ + actionId: createVcsActionTransportId(otherTarget, actionId), + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }); + + expect(normalizeVcsActionProgressEvent(target, transportActionId, actionId, event)).toBeNull(); + expect( + normalizeVcsActionProgressEvent(target, transportActionId, actionId, { + ...event, + actionId: transportActionId, + }), + ).toEqual({ + ...event, + actionId, + }); + }); + + it.effect("consumes progress through the terminal event and returns its result", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const observed: GitActionProgressEvent[] = []; + const events: GitActionProgressEvent[] = [ + { + actionId: "unrelated-action", + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Ignored", + }, + { + actionId: transportActionId, + action, + cwd, + kind: "phase_started", + phase: "push", + label: "Pushing...", + }, + { + actionId: transportActionId, + action, + cwd, + kind: "action_finished", + result, + }, + ]; + + const actual = yield* consumeVcsActionProgress(Stream.fromIterable(events), { + target, + transportActionId, + actionId, + onProgress: (event) => + Effect.sync(() => { + observed.push(event); + }), + }); + + expect(actual).toEqual(result); + expect(observed.map((event) => event.actionId)).toEqual([actionId, actionId]); + expect(observed.map((event) => event.kind)).toEqual(["phase_started", "action_finished"]); + }), + ); + + it("keys mutation ownership by environment and cwd", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const target = { environmentId, cwd }; + const otherTarget = { + environmentId: EnvironmentId.make("environment-2"), + cwd, + }; + + expect(manager.runStackedAction(target)).toBe(manager.runStackedAction({ ...target })); + expect(manager.runStackedAction(target)).not.toBe(manager.runStackedAction(otherTarget)); + expect(registry.get(manager.stateAtom(target))).toEqual(EMPTY_VCS_ACTION_STATE); + + registry.dispose(); + }); + + it("tracks finite mutations without letting an older completion clear newer state", async () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const target = { environmentId, cwd }; + let finishFirst!: () => void; + let failSecond!: (error: Error) => void; + const firstAction = new Promise>((resolve) => { + finishFirst = () => resolve(AsyncResult.success(undefined)); + }); + const secondAction = new Promise>((resolve) => { + failSecond = (error) => resolve(AsyncResult.failure(Cause.fail(error))); + }); + + const first = manager.track( + registry, + target, + { operation: "pull", label: "Pulling latest changes" }, + () => firstAction, + ); + const firstActionId = registry.get(manager.stateAtom(target)).actionId; + const second = manager.track( + registry, + target, + { operation: "switch_ref", label: "Switching branch" }, + () => secondAction, + ); + const secondActionId = registry.get(manager.stateAtom(target)).actionId; + + finishFirst(); + await first; + expect(registry.get(manager.stateAtom(target))).toMatchObject({ + actionId: secondActionId, + isRunning: true, + operation: "switch_ref", + }); + expect(secondActionId).not.toBe(firstActionId); + + failSecond(new Error("switch failed")); + const secondFailure = await second; + expect(AsyncResult.isFailure(secondFailure)).toBe(true); + expect(registry.get(manager.stateAtom(target))).toMatchObject({ + actionId: secondActionId, + error: "switch failed", + isRunning: false, + operation: "switch_ref", + }); + + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts new file mode 100644 index 00000000000..b06f5ac65bc --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -0,0 +1,499 @@ +import { + EnvironmentId, + type EnvironmentId as EnvironmentIdType, + type GitActionProgressEvent, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type GitStackedAction, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { runStream } from "../rpc/client.ts"; +import { + createRuntimeCommand, + runStreamInEnvironment, + type AtomCommand, + type AtomCommandResult, +} from "./runtime.ts"; +import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; + +export type VcsActionOperation = + | "refresh_status" + | "run_change_request" + | "pull" + | "switch_ref" + | "create_ref" + | "create_worktree" + | "init" + | "publish_repository" + | "prepare_pull_request_thread"; + +export interface VcsActionState { + readonly isRunning: boolean; + readonly operation: VcsActionOperation | null; + readonly actionId: string | null; + readonly action: GitStackedAction | null; + readonly currentLabel: string | null; + readonly currentPhaseLabel: string | null; + readonly hookName: string | null; + readonly lastOutputLine: string | null; + readonly phaseStartedAtMs: number | null; + readonly hookStartedAtMs: number | null; + readonly error: string | null; +} + +export interface VcsActionTarget { + readonly environmentId: EnvironmentIdType | null; + readonly cwd: string | null; +} + +export interface ResolvedVcsActionTarget { + readonly environmentId: EnvironmentIdType; + readonly cwd: string; +} + +export interface BeginVcsActionInput { + readonly operation: VcsActionOperation; + readonly label: string; + readonly actionId?: string; +} + +export interface RunVcsStackedActionInput { + readonly actionId: string; + readonly action: GitStackedAction; + readonly commitMessage?: string; + readonly featureBranch?: boolean; + readonly filePaths?: ReadonlyArray; + readonly onProgress?: (event: GitActionProgressEvent) => void; +} + +export class VcsActionUnavailableError extends Schema.TaggedErrorClass()( + "VcsActionUnavailableError", + { + message: Schema.String, + }, +) {} + +export class VcsActionExecutionError extends Schema.TaggedErrorClass()( + "VcsActionExecutionError", + { + message: Schema.String, + }, +) {} + +export const EMPTY_VCS_ACTION_STATE = Object.freeze({ + isRunning: false, + operation: null, + actionId: null, + action: null, + currentLabel: null, + currentPhaseLabel: null, + hookName: null, + lastOutputLine: null, + phaseStartedAtMs: null, + hookStartedAtMs: null, + error: null, +}); + +const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); +let nextLocalActionId = 0; + +export const vcsActionStateAtom = Atom.family((key: string) => { + return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`vcs-action:${key}`), + ); +}); + +export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("vcs-action:null"), +); + +export function getVcsActionTargetKey(target: VcsActionTarget): string | null { + if (target.environmentId === null || target.cwd === null) { + return null; + } + return JSON.stringify([target.environmentId, target.cwd]); +} + +function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { + const [environmentId, cwd] = JSON.parse(key) as [string, string]; + return { + environmentId: EnvironmentId.make(environmentId), + cwd, + }; +} + +export function getVcsActionStateAtom(target: VcsActionTarget) { + const key = getVcsActionTargetKey(target); + return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); +} + +function createLocalActionId(): string { + nextLocalActionId += 1; + return `local-vcs-action:${nextLocalActionId}`; +} + +export function beginVcsActionState( + input: BeginVcsActionInput, +): VcsActionState & { readonly actionId: string } { + const actionId = input.actionId ?? createLocalActionId(); + const startedAt = nowMs(); + return { + ...EMPTY_VCS_ACTION_STATE, + isRunning: true, + operation: input.operation, + actionId, + currentLabel: input.label, + currentPhaseLabel: input.label, + phaseStartedAtMs: startedAt, + }; +} + +export function failVcsActionState( + operation: VcsActionOperation, + actionId: string, + error: unknown, +): VcsActionState { + return { + ...EMPTY_VCS_ACTION_STATE, + operation, + actionId, + error: error instanceof Error ? error.message : "Source control action failed.", + }; +} + +export function createVcsActionTransportId( + target: ResolvedVcsActionTarget, + actionId: string, +): string { + const targetKey = JSON.stringify([target.environmentId, target.cwd]); + return `${targetKey.length}:${targetKey}${actionId}`; +} + +export function normalizeVcsActionProgressEvent( + target: ResolvedVcsActionTarget, + transportActionId: string, + actionId: string, + event: GitActionProgressEvent, +): GitActionProgressEvent | null { + if (event.actionId !== transportActionId || event.cwd !== target.cwd) { + return null; + } + return { + ...event, + actionId, + }; +} + +export function consumeVcsActionProgress( + stream: Stream.Stream, + input: { + readonly target: ResolvedVcsActionTarget; + readonly transportActionId: string; + readonly actionId: string; + readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect; + }, +): Effect.Effect { + return Effect.suspend(() => { + let terminalEvent: GitActionProgressEvent | null = null; + return stream.pipe( + Stream.runForEach((event) => { + const normalized = normalizeVcsActionProgressEvent( + input.target, + input.transportActionId, + input.actionId, + event, + ); + if (normalized === null) { + return Effect.void; + } + if (normalized.kind === "action_finished" || normalized.kind === "action_failed") { + terminalEvent = normalized; + } + return input.onProgress(normalized); + }), + Effect.flatMap(() => { + if (terminalEvent?.kind === "action_finished") { + return Effect.succeed(terminalEvent.result); + } + if (terminalEvent?.kind === "action_failed") { + return Effect.fail( + new VcsActionExecutionError({ + message: terminalEvent.message, + }), + ); + } + return Effect.fail( + new VcsActionExecutionError({ + message: "Source control action ended without a result.", + }), + ); + }), + ); + }); +} + +export function applyVcsActionProgressEvent( + current: VcsActionState, + event: GitActionProgressEvent, +): VcsActionState { + if (current.actionId !== event.actionId) { + return current; + } + const now = nowMs(); + + switch (event.kind) { + case "action_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "phase_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: event.label, + currentPhaseLabel: event.label, + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "hook_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: `Running ${event.hookName}...`, + hookName: event.hookName, + hookStartedAtMs: now, + lastOutputLine: null, + error: null, + }; + case "hook_output": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + lastOutputLine: event.text, + error: null, + }; + case "hook_finished": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: current.currentPhaseLabel, + hookName: null, + hookStartedAtMs: null, + lastOutputLine: null, + error: null, + }; + case "action_finished": + return { + ...current, + isRunning: false, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: null, + }; + case "action_failed": + return { + ...EMPTY_VCS_ACTION_STATE, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: event.message, + }; + } +} + +export function createVcsActionManager( + runtime: Atom.AtomRuntime, +) { + const unavailableTargetKey = "vcs-action-target:unavailable"; + const runStackedActionCommands = new Map< + string, + AtomCommand + >(); + const getRunStackedActionCommand = (key: string) => { + const existing = runStackedActionCommands.get(key); + if (existing !== undefined) { + return existing; + } + const target = key === unavailableTargetKey ? null : parseVcsActionTargetKey(key); + const stateAtom = target === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); + const command = createRuntimeCommand< + EnvironmentRegistry | R, + E, + RunVcsStackedActionInput, + GitRunStackedActionResult, + unknown + >(runtime, { + label: `vcs-action:run-stacked:${key}`, + scheduler: vcsCommandScheduler, + concurrency: { mode: "serial", key: () => key }, + execute: (input: RunVcsStackedActionInput, registry) => { + if (target === null) { + return Effect.fail( + new VcsActionUnavailableError({ + message: "Source control action is unavailable.", + }), + ); + } + const transportActionId = createVcsActionTransportId(target, input.actionId); + registry.set( + stateAtom, + beginVcsActionState({ + operation: "run_change_request", + label: "Running source control action", + actionId: input.actionId, + }), + ); + + const rpcInput: GitRunStackedActionInput = { + actionId: transportActionId, + cwd: target.cwd, + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + }; + return consumeVcsActionProgress( + runStreamInEnvironment( + target.environmentId, + runStream(WS_METHODS.gitRunStackedAction, rpcInput), + ), + { + target, + transportActionId, + actionId: input.actionId, + onProgress: (event) => + Effect.sync(() => { + const current = registry.get(stateAtom); + if (current.actionId !== input.actionId) { + return; + } + registry.set(stateAtom, applyVcsActionProgressEvent(current, event)); + if (input.onProgress !== undefined) { + try { + input.onProgress(event); + } catch { + // Presentation callbacks must not fail the source-control operation. + } + } + }), + }, + ).pipe( + Effect.tapError((error) => + Effect.sync(() => { + const current = registry.get(stateAtom); + if (current.actionId === input.actionId && current.isRunning) { + registry.set( + stateAtom, + failVcsActionState("run_change_request", input.actionId, error), + ); + } + }), + ), + ); + }, + }); + runStackedActionCommands.set(key, command); + return command; + }; + + const setState = ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + update: (current: VcsActionState) => VcsActionState, + ): void => { + const key = getVcsActionTargetKey(target); + if (key === null) { + return; + } + const stateAtom = vcsActionStateAtom(key); + registry.set(stateAtom, update(registry.get(stateAtom))); + }; + + return { + stateAtom: getVcsActionStateAtom, + runStackedAction: (target: VcsActionTarget) => { + const key = getVcsActionTargetKey(target); + return getRunStackedActionCommand(key ?? unavailableTargetKey); + }, + track: async ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + input: BeginVcsActionInput, + action: () => Promise>, + ): Promise> => { + const key = getVcsActionTargetKey(target); + if (key === null) { + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + message: "Source control action is unavailable.", + }), + ), + ); + } + const stateAtom = vcsActionStateAtom(key); + const next = beginVcsActionState(input); + registry.set(stateAtom, next); + const result = await action(); + const current = registry.get(stateAtom); + if (current.actionId !== next.actionId) { + return result; + } + if (AsyncResult.isSuccess(result) || Cause.hasInterruptsOnly(result.cause)) { + registry.set(stateAtom, EMPTY_VCS_ACTION_STATE); + } else { + if (registry.get(stateAtom).actionId === next.actionId) { + registry.set( + stateAtom, + failVcsActionState(input.operation, next.actionId, Cause.squash(result.cause)), + ); + } + } + return result; + }, + resetError: ( + registry: AtomRegistry.AtomRegistry, + target: VcsActionTarget, + operation: VcsActionOperation, + ): void => { + setState(registry, target, (current) => + !current.isRunning && current.operation === operation ? EMPTY_VCS_ACTION_STATE : current, + ); + }, + }; +} diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts new file mode 100644 index 00000000000..a11b157bb2d --- /dev/null +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { createAtomCommandScheduler, type AtomCommandConcurrency } from "./runtime.ts"; + +export const vcsCommandScheduler = createAtomCommandScheduler(); + +export const vcsCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: { readonly cwd: string }; +}> = { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), +}; diff --git a/packages/client-runtime/src/state/vcsRef.ts b/packages/client-runtime/src/state/vcsRef.ts new file mode 100644 index 00000000000..5e879356d2f --- /dev/null +++ b/packages/client-runtime/src/state/vcsRef.ts @@ -0,0 +1,9 @@ +import type { EnvironmentId, VcsRef as ContractVcsRef } from "@t3tools/contracts"; + +export interface VcsRefTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +} + +export type VcsRef = ContractVcsRef; diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts new file mode 100644 index 00000000000..0a301fa86f3 --- /dev/null +++ b/packages/client-runtime/src/state/vcsStatus.ts @@ -0,0 +1,6 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface VcsStatusTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} diff --git a/packages/client-runtime/src/terminalSessionState.test.ts b/packages/client-runtime/src/terminalSessionState.test.ts deleted file mode 100644 index 401536915de..00000000000 --- a/packages/client-runtime/src/terminalSessionState.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; - -import { - createTerminalSessionManager, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSessionTarget, -} from "./terminalSessionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", -} as const; - -const BASE_SNAPSHOT: TerminalSessionSnapshot = { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - history: "hello", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-01T00:00:00.000Z", -}; - -type TerminalSessionManager = ReturnType; - -function applyAttachEvents( - manager: TerminalSessionManager, - target: KnownTerminalSessionTarget, - events: ReadonlyArray, -): void { - manager.attach({ - environmentId: target.environmentId, - terminal: { - threadId: target.threadId, - terminalId: target.terminalId, - }, - client: { - terminal: { - attach: (_input, listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -function applyMetadataEvents( - manager: TerminalSessionManager, - environmentId: EnvironmentId, - events: ReadonlyArray, -): void { - manager.subscribeMetadata({ - environmentId, - client: { - terminal: { - onMetadata: (listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -describe("createTerminalSessionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("hydrates from started snapshots and appends output events", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: " world", - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - summary: null, - buffer: "hello world", - status: "running", - error: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("caps retained output", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 5, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "abcdef", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef"); - }); - - it("caps retained output by utf-8 byte length", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 4, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "🙂🙂", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("🙂"); - }); - - it("invalidates one environment without clearing others", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - const otherTarget = { - environmentId: EnvironmentId.make("env-remote"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", - } as const; - - for (const target of [TARGET, otherTarget]) { - applyAttachEvents(manager, target, [ - { - type: "output", - threadId: target.threadId, - terminalId: target.terminalId, - data: target.environmentId, - }, - ]); - } - - manager.invalidateEnvironment(TARGET.environmentId); - - expect(manager.getSnapshot(TARGET).buffer).toBe(""); - expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); - }); - - it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-10", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 125, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: false, - label: "Terminal 10", - }, - { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:00.000Z", - hasRunningSubprocess: false, - label: "Terminal 1", - }, - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 124, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:02.000Z", - hasRunningSubprocess: false, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager - .listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }) - .map((session) => session.target.terminalId), - ).toEqual(["term-1", "term-2", "term-10"]); - }); - - it("drops known sessions when an environment is invalidated", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "hello", - }, - ]); - - manager.invalidateEnvironment(TARGET.environmentId); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - }); - - it("removes closed sessions from the known-session index while keeping local closed state", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "remove", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "hello", - status: "closed", - summary: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("clears locally retained closed state on reset", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, - }); - }); - - it("syncs snapshots returned from open calls immediately", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - history: "prompt$ ", - updatedAt: "2026-04-01T00:00:03.000Z", - }, - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "prompt$ ", - status: "running", - updatedAt: "2026-04-01T00:00:03.000Z", - }); - }); - - it("syncs authoritative metadata snapshots and removes missing environment terminals", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - applyAttachEvents( - manager, - { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - terminalId: "term-2", - label: "Terminal 2", - updatedAt: "2026-04-01T00:00:02.000Z", - }, - }, - ], - ); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toMatchObject([ - { - target: { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - state: { - summary: { - terminalId: "term-2", - cwd: "/repo", - }, - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("updates listed session metadata when existing session activity changes", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - - expect( - manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("derives session atoms from structurally equal target objects", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - - const equalTarget = { ...TARGET }; - const filter = getKnownTerminalSessionListFilter({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }); - expect(filter).not.toBeNull(); - if (filter === null) { - return; - } - - expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({ - buffer: BASE_SNAPSHOT.history, - hasRunningSubprocess: true, - }); - expect( - atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target), - ).toEqual([TARGET]); - expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]); - }); -}); diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts deleted file mode 100644 index 668ac343a49..00000000000 --- a/packages/client-runtime/src/terminalSessionState.ts +++ /dev/null @@ -1,605 +0,0 @@ -import type { - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - TerminalSummary, - EnvironmentId, -} from "@t3tools/contracts"; -import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Result from "effect/Result"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface TerminalSessionState { - readonly summary: TerminalSummary | null; - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly hasRunningSubprocess: boolean; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalBufferState { - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalSessionTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface KnownTerminalSessionTarget { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly terminalId: string; -} - -export interface KnownTerminalSession { - readonly target: KnownTerminalSessionTarget; - readonly state: TerminalSessionState; -} - -export interface KnownTerminalMetadata { - readonly target: KnownTerminalSessionTarget; - readonly summary: TerminalSummary; -} - -export interface TerminalSessionListFilter { - readonly environmentId: EnvironmentId | null; - readonly threadId?: ThreadId | null; - readonly terminalId?: string | null; -} - -export interface KnownTerminalSessionListFilter { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface TerminalSessionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly maxBufferBytes?: number; -} - -export interface TerminalMetadataClient { - readonly terminal: { - readonly onMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export interface TerminalAttachClient { - readonly terminal: { - readonly attach: ( - input: TerminalAttachInput, - listener: (event: TerminalAttachStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ - buffer: "", - status: "closed", - error: null, - updatedAt: null, - version: 0, -}); - -export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, -}); - -const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); -const EMPTY_TERMINAL_ID_LIST = Object.freeze>([]); -const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024; -const knownTerminalMetadataEnvironmentIds = new Set(); -const knownTerminalBufferTargets = new Map(); -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); -const terminalIdOrder = Order.make( - (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, -); -const knownTerminalSessionOrder = Order.mapInput( - terminalIdOrder, - (session: KnownTerminalSession) => session.target.terminalId, -); - -export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => { - knownTerminalMetadataEnvironmentIds.add(environmentId); - return Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:metadata:${environmentId}`), - ); -}); - -export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => { - const key = keyFromKnownTarget(target); - knownTerminalBufferTargets.set(key, target); - return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:buffer:${key}`), - ); -}); - -export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:buffer:null"), -); - -export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:state:null"), -); - -export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:known:null"), -); - -export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:running-terminal-ids:null"), -); - -export function getKnownTerminalSessionTarget( - target: TerminalSessionTarget, -): KnownTerminalSessionTarget | null { - if (target.environmentId === null || target.threadId === null || target.terminalId === null) { - return null; - } - - return { - environmentId: target.environmentId, - threadId: target.threadId, - terminalId: target.terminalId, - }; -} - -export function getKnownTerminalSessionListFilter( - filter: TerminalSessionListFilter, -): KnownTerminalSessionListFilter | null { - if (filter.environmentId === null) { - return null; - } - - return { - environmentId: filter.environmentId, - threadId: filter.threadId ?? null, - terminalId: filter.terminalId ?? null, - }; -} - -function knownTargetFromSummary( - environmentId: EnvironmentId, - summary: TerminalSummary, -): KnownTerminalSessionTarget { - return { - environmentId, - threadId: ThreadId.make(summary.threadId), - terminalId: summary.terminalId, - }; -} - -function keyFromKnownTarget(target: KnownTerminalSessionTarget): string { - return `${target.environmentId}:${target.threadId}:${target.terminalId}`; -} - -function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { - if (maxBufferBytes <= 0) { - return ""; - } - - const encoded = textEncoder.encode(buffer); - if (encoded.byteLength <= maxBufferBytes) { - return buffer; - } - - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; - if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { - break; - } - start += 1; - } - - return textDecoder.decode(encoded.subarray(start)); -} - -function bufferFromSnapshot( - snapshot: TerminalSessionSnapshot, - maxBufferBytes: number, -): TerminalBufferState { - return { - buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), - status: snapshot.status, - error: null, - updatedAt: snapshot.updatedAt, - version: 1, - }; -} - -function latestTimestamp(left: string | null, right: string | null): string | null { - if (left === null) return right; - if (right === null) return left; - return Date.parse(left) >= Date.parse(right) ? left : right; -} - -function combineSessionState( - summary: TerminalSummary | null, - buffer: TerminalBufferState, -): TerminalSessionState { - return { - summary, - buffer: buffer.buffer, - status: summary?.status ?? buffer.status, - error: buffer.error, - hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, - updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), - version: buffer.version, - }; -} - -function listKnownSessionsFromMetadata( - metadata: Record, - getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState, - filter?: Partial, -): ReadonlyArray { - return pipe( - Object.values(metadata), - Arr.filterMap(({ target, summary }) => { - if (filter?.environmentId && target.environmentId !== filter.environmentId) { - return Result.failVoid; - } - if (filter?.threadId && target.threadId !== filter.threadId) { - return Result.failVoid; - } - if (filter?.terminalId && target.terminalId !== filter.terminalId) { - return Result.failVoid; - } - return Result.succeed({ - target, - state: combineSessionState(summary, getBuffer(target)), - }); - }), - Arr.sort(knownTerminalSessionOrder), - ); -} - -export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => - Atom.make((get) => { - const targetKey = keyFromKnownTarget(target); - return combineSessionState( - get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null, - get(terminalSessionBufferAtom(target)), - ); - }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)), -); - -export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => - listKnownSessionsFromMetadata( - get(terminalSessionMetadataAtom(filter.environmentId)), - (target) => get(terminalSessionBufferAtom(target)), - { - environmentId: filter.environmentId, - ...(filter.threadId !== null ? { threadId: filter.threadId } : {}), - ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}), - }, - ), - ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)), -); - -export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => { - return pipe( - Object.values(get(terminalSessionMetadataAtom(filter.environmentId))), - Arr.filterMap((entry) => - entry.target.environmentId === filter.environmentId && - (filter.threadId === null || entry.target.threadId === filter.threadId) && - (filter.terminalId === null || entry.target.terminalId === filter.terminalId) && - entry.summary.hasRunningSubprocess - ? Result.succeed(entry.target.terminalId) - : Result.failVoid, - ), - Arr.sort(Order.String), - ); - }).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`), - ), -); - -export function createTerminalSessionManager(config: TerminalSessionManagerConfig) { - const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; - - function getMetadata(environmentId: EnvironmentId): Record { - return config.getRegistry().get(terminalSessionMetadataAtom(environmentId)); - } - - function setMetadata( - environmentId: EnvironmentId, - next: Record, - ): void { - config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next); - } - - function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState { - return config.getRegistry().get(terminalSessionBufferAtom(target)); - } - - function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void { - config.getRegistry().set(terminalSessionBufferAtom(target), next); - } - - function getSnapshot(target: TerminalSessionTarget): TerminalSessionState { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget === null) { - return EMPTY_TERMINAL_SESSION_STATE; - } - - return combineSessionState( - getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null, - getBuffer(knownTarget), - ); - } - - function syncSnapshot( - target: Pick, - snapshot: TerminalSessionSnapshot, - ): void { - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(snapshot.threadId), - terminalId: snapshot.terminalId, - }); - if (knownTarget === null) { - return; - } - - setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes)); - } - - function applyMetadataEvent( - target: Pick, - event: TerminalMetadataStreamEvent, - ): void { - const environmentId = target.environmentId; - if (environmentId === null) { - return; - } - - if (event.type === "snapshot") { - const retainedKeys = new Set(); - const next = { ...getMetadata(environmentId) }; - - for (const terminal of event.terminals) { - const knownTarget = knownTargetFromSummary(environmentId, terminal); - const targetKey = keyFromKnownTarget(knownTarget); - retainedKeys.add(targetKey); - next[targetKey] = { - target: knownTarget, - summary: terminal, - }; - } - - for (const key of Object.keys(next)) { - if (!retainedKeys.has(key)) { - delete next[key]; - } - } - - setMetadata(environmentId, next); - return; - } - - if (event.type === "upsert") { - const knownTarget = knownTargetFromSummary(environmentId, event.terminal); - const targetKey = keyFromKnownTarget(knownTarget); - setMetadata(environmentId, { - ...getMetadata(environmentId), - [targetKey]: { - target: knownTarget, - summary: event.terminal, - }, - }); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const next = { ...getMetadata(environmentId) }; - delete next[keyFromKnownTarget(knownTarget)]; - setMetadata(environmentId, next); - } - - function applyAttachEvent( - target: Pick, - event: TerminalAttachStreamEvent, - ): void { - if (event.type === "snapshot") { - syncSnapshot(target, event.snapshot); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const current = getBuffer(knownTarget); - switch (event.type) { - case "restarted": - setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes)); - return; - case "output": - setBuffer(knownTarget, { - ...current, - buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), - status: current.status === "closed" ? "running" : current.status, - error: null, - version: current.version + 1, - }); - return; - case "cleared": - setBuffer(knownTarget, { - ...current, - buffer: "", - error: null, - version: current.version + 1, - }); - return; - case "exited": - setBuffer(knownTarget, { - ...current, - status: "exited", - error: null, - version: current.version + 1, - }); - return; - case "closed": - setBuffer(knownTarget, { - ...current, - status: "closed", - error: null, - version: current.version + 1, - }); - return; - case "error": - setBuffer(knownTarget, { - ...current, - status: "error", - error: event.message, - version: current.version + 1, - }); - return; - case "activity": - return; - } - } - - function invalidate(target?: TerminalSessionTarget): void { - if (target) { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget !== null) { - const targetKey = keyFromKnownTarget(knownTarget); - const next = { ...getMetadata(knownTarget.environmentId) }; - delete next[targetKey]; - setMetadata(knownTarget.environmentId, next); - setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE); - } - return; - } - - for (const environmentId of knownTerminalMetadataEnvironmentIds) { - setMetadata(environmentId, {}); - } - knownTerminalMetadataEnvironmentIds.clear(); - for (const target of knownTerminalBufferTargets.values()) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - knownTerminalBufferTargets.clear(); - } - - function invalidateEnvironment(environmentId: EnvironmentId): void { - setMetadata(environmentId, {}); - knownTerminalMetadataEnvironmentIds.delete(environmentId); - - const prefix = `${environmentId}:`; - for (const [key, target] of knownTerminalBufferTargets) { - if (key.startsWith(prefix)) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - } - } - - function reset(): void { - invalidate(); - } - - function listSessions( - filter?: Partial, - ): ReadonlyArray { - if (filter?.environmentId) { - return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter); - } - - return pipe( - knownTerminalMetadataEnvironmentIds, - Arr.fromIterable, - Arr.flatMap((environmentId) => - listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter), - ), - ); - } - - function subscribeMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalMetadataClient; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.onMetadata( - (event) => applyMetadataEvent({ environmentId: input.environmentId }, event), - input.options, - ); - } - - function attach(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalAttachClient; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.attach( - input.terminal, - (event) => { - applyAttachEvent({ environmentId: input.environmentId }, event); - input.onEvent?.(event); - if (event.type === "snapshot") { - input.onSnapshot?.(event.snapshot); - } - }, - input.options, - ); - } - - return { - attach, - getSnapshot, - invalidate, - invalidateEnvironment, - listSessions, - subscribeMetadata, - reset, - }; -} diff --git a/packages/client-runtime/src/threadDetailState.test.ts b/packages/client-runtime/src/threadDetailState.test.ts deleted file mode 100644 index df482ce1f6b..00000000000 --- a/packages/client-runtime/src/threadDetailState.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - EventId, - EnvironmentId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThread, - type OrchestrationThreadStreamItem, -} from "@t3tools/contracts"; - -import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const baseEventFields = { - eventId: EventId.make("event-1"), - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, -} as const; - -const BASE_THREAD: OrchestrationThread = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Test Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, -}; - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), -} as const; - -function createMockClient(): { - client: ThreadDetailClient; - listeners: Set<(event: OrchestrationThreadStreamItem) => void>; - emit: (event: OrchestrationThreadStreamItem) => void; -} { - const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>(); - const client: ThreadDetailClient = { - subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) => - registerListener(listeners, listener), - ), - }; - - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) { - listener(event); - } - }, - }; -} - -describe("createThreadDetailManager", () => { - afterEach(() => { - vi.useRealTimers(); - resetAtomRegistry(); - }); - - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - isDeleted: false, - }); - }); - - it("applies snapshots and incremental events", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 2, - occurredAt: "2026-04-01T01:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - messageId: MessageId.make("message-1"), - role: "assistant", - text: "hello", - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...BASE_THREAD, - updatedAt: "2026-04-01T01:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-01T01:00:00.000Z", - startedAt: "2026-04-01T01:00:00.000Z", - completedAt: "2026-04-01T01:00:00.000Z", - assistantMessageId: MessageId.make("message-1"), - }, - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - ], - }, - error: null, - isPending: false, - isDeleted: false, - }); - - release(); - }); - - it("marks threads as deleted when the stream deletes them", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 3, - occurredAt: "2026-04-01T01:10:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.deleted", - payload: { - threadId: ThreadId.make("thread-1"), - deletedAt: "2026-04-01T01:10:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - - release(); - }); - - it("waits for delayed client registration when subscribeClientChanges is configured", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: (environmentId) => clients.get(environmentId)?.client ?? null, - getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET).isPending).toBe(true); - - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) { - listener(); - } - - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1")); - - release(); - }); - - it("evicts idle subscriptions after the configured ttl", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - }, - }); - - const release = manager.watch(TARGET); - expect(mock.listeners.size).toBe(1); - - release(); - expect(mock.listeners.size).toBe(1); - - vi.advanceTimersByTime(60_000); - expect(mock.listeners.size).toBe(0); - }); - - it("keeps non-idle threads warm when the retention policy says to", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - shouldKeepWarm: (_target, state) => state.data?.session?.status === "running", - }, - }); - - const release = manager.watch(TARGET); - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: { - ...BASE_THREAD, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-04-01T00:10:00.000Z", - }, - }, - }, - }); - - release(); - vi.advanceTimersByTime(60_000); - - expect(mock.listeners.size).toBe(1); - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); -}); diff --git a/packages/client-runtime/src/threadDetailState.ts b/packages/client-runtime/src/threadDetailState.ts deleted file mode 100644 index d8c5cc4add4..00000000000 --- a/packages/client-runtime/src/threadDetailState.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import type { - OrchestrationThread, - OrchestrationThreadStreamItem, - EnvironmentId, - ThreadId as ThreadIdType, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { - DEFAULT_THREAD_DETAIL_LIMITS, - applyThreadDetailEvent, - type ThreadDetailRetentionLimits, -} from "./threadDetailReducer.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface ThreadDetailState { - readonly data: OrchestrationThread | null; - readonly error: string | null; - readonly isPending: boolean; - readonly isDeleted: boolean; -} - -export interface ThreadDetailTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadIdType | null; -} - -export type ThreadDetailClient = Pick; - -export interface ThreadDetailRetentionPolicy { - readonly idleTtlMs: number; - readonly maxRetainedEntries: number; - readonly shouldKeepWarm?: ( - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - state: ThreadDetailState, - ) => boolean; -} - -interface ThreadDetailEntry { - readonly target: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadIdType; - }; - watcherCount: number; - retainCount: number; - teardown: () => void; - lastAccessedAt: number; - evictionFiber: Fiber.Fiber | null; -} - -const NOOP: () => void = () => undefined; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -function clearEntryEviction(entry: ThreadDetailEntry): void { - if (entry.evictionFiber !== null) { - Effect.runFork(Fiber.interrupt(entry.evictionFiber)); - entry.evictionFiber = null; - } -} - -export const EMPTY_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, - isDeleted: false, -}); - -const INITIAL_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, - isDeleted: false, -}); - -const knownThreadDetailKeys = new Set(); - -export const threadDetailStateAtom = Atom.family((key: string) => { - knownThreadDetailKeys.add(key); - return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`thread-detail:${key}`), - ); -}); - -export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("thread-detail:null"), -); - -export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null { - if (target.environmentId === null || target.threadId === null) { - return null; - } - - return `${target.environmentId}:${target.threadId}`; -} - -export interface ThreadDetailManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null; - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly limits?: ThreadDetailRetentionLimits; - readonly retention?: ThreadDetailRetentionPolicy; -} - -export function createThreadDetailManager(config: ThreadDetailManagerConfig) { - const entries = new Map(); - - function getSnapshot(target: ThreadDetailTarget): ThreadDetailState { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null) { - return EMPTY_THREAD_DETAIL_STATE; - } - - return config.getRegistry().get(threadDetailStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ThreadDetailState): void { - config.getRegistry().set(threadDetailStateAtom(targetKey), nextState); - reconcileRetention(targetKey); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(threadDetailStateAtom(targetKey)); - setState(targetKey, { - ...current, - error: null, - isPending: true, - }); - } - - function setData(targetKey: string, thread: OrchestrationThread): void { - setState(targetKey, { - data: thread, - error: null, - isPending: false, - isDeleted: false, - }); - } - - function setDeleted(targetKey: string): void { - setState(targetKey, { - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - } - - function shouldKeepWarm(entry: ThreadDetailEntry): boolean { - return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false; - } - - function disposeEntry(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - entry.teardown(); - entries.delete(targetKey); - } - - function evictIdleEntriesToCapacity(): void { - const retention = config.retention; - if (!retention || entries.size <= retention.maxRetainedEntries) { - return; - } - - const idleEntries = pipe( - Arr.fromIterable(entries), - Arr.filter( - ([, entry]) => - entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry), - ), - Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number), - ); - - for (const [targetKey] of idleEntries) { - if (entries.size <= retention.maxRetainedEntries) { - return; - } - disposeEntry(targetKey); - } - } - - function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void { - const retention = config.retention; - clearEntryEviction(entry); - - if (!retention) { - disposeEntry(targetKey); - return; - } - - if (retention.idleTtlMs <= 0) { - disposeEntry(targetKey); - return; - } - - entry.evictionFiber = Effect.runFork( - Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe( - Effect.andThen( - Effect.sync(() => { - const current = entries.get(targetKey); - if (!current) { - return; - } - - current.evictionFiber = null; - if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) { - return; - } - - disposeEntry(targetKey); - }), - ), - ), - ); - } - - function reconcileRetention(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) { - return; - } - - scheduleEviction(targetKey, entry); - evictIdleEntriesToCapacity(); - } - - function applyStreamItem( - targetKey: string, - item: OrchestrationThreadStreamItem, - threadId: ThreadIdType, - ): void { - if (item.kind === "snapshot") { - setData(targetKey, item.snapshot.thread); - return; - } - - const current = getSnapshot({ - environmentId: entries.get(targetKey)?.target.environmentId ?? null, - threadId, - }).data; - - if (current === null) { - if (item.event.type === "thread.deleted") { - setDeleted(targetKey); - } - return; - } - - const result = applyThreadDetailEvent( - current, - item.event, - config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS, - ); - - if (result.kind === "updated") { - setData(targetKey, result.thread); - return; - } - - if (result.kind === "deleted") { - setDeleted(targetKey); - } - } - - function subscribeStream( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - client: ThreadDetailClient, - ): () => void { - markPending(targetKey); - return client.subscribeThread( - { threadId: target.threadId }, - (item) => applyStreamItem(targetKey, item, target.threadId), - { - onResubscribe: () => markPending(targetKey), - }, - ); - } - - function createDynamicSubscription( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - ): () => void { - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(target.environmentId); - const identity = client - ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId) - : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) { - return; - } - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, target, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - function acquire( - target: ThreadDetailTarget, - kind: "watcher" | "retain", - client?: ThreadDetailClient, - ): () => void { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null || target.environmentId === null || target.threadId === null) { - return NOOP; - } - - const existing = entries.get(targetKey); - if (existing) { - clearEntryEviction(existing); - existing.lastAccessedAt = nowMs(); - if (kind === "watcher") { - existing.watcherCount += 1; - } else { - existing.retainCount += 1; - } - return () => release(targetKey, kind); - } - - let teardown: () => void; - const resolvedTarget = { - environmentId: target.environmentId, - threadId: target.threadId, - }; - - if (client) { - teardown = subscribeStream(targetKey, resolvedTarget, client); - } else if (config.subscribeClientChanges) { - teardown = createDynamicSubscription(targetKey, resolvedTarget); - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - teardown = subscribeStream(targetKey, resolvedTarget, resolved); - } - - entries.set(targetKey, { - target: resolvedTarget, - watcherCount: kind === "watcher" ? 1 : 0, - retainCount: kind === "retain" ? 1 : 0, - teardown, - lastAccessedAt: nowMs(), - evictionFiber: null, - }); - evictIdleEntriesToCapacity(); - return () => release(targetKey, kind); - } - - function release(targetKey: string, kind: "watcher" | "retain"): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - if (kind === "watcher") { - entry.watcherCount = Math.max(0, entry.watcherCount - 1); - } else { - entry.retainCount = Math.max(0, entry.retainCount - 1); - } - entry.lastAccessedAt = nowMs(); - reconcileRetention(targetKey); - } - - function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "watcher", client); - } - - function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "retain", client); - } - - function invalidate(target?: ThreadDetailTarget): void { - if (target) { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey !== null) { - disposeEntry(targetKey); - config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE); - } - return; - } - - for (const targetKey of entries.keys()) { - disposeEntry(targetKey); - } - for (const key of knownThreadDetailKeys) { - config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - watch, - retain, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsActionState.test.ts b/packages/client-runtime/src/vcsActionState.test.ts deleted file mode 100644 index f653b26b34f..00000000000 --- a/packages/client-runtime/src/vcsActionState.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type VcsCreateRefResult, - type VcsCreateWorktreeResult, - type VcsPullResult, - type VcsStatusResult, - type VcsSwitchRefResult, -} from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type VcsActionClient, - createVcsActionManager, - EMPTY_VCS_ACTION_STATE, -} from "./vcsActionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/test", - hasWorkingTreeChanges: true, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createPhaseStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }; -} - -function createHookStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_started", - hookName: "post-commit", - }; -} - -function createHookOutputEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_output", - hookName: "post-commit", - stream: "stdout", - text: "hook output", - }; -} - -function createHookFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_finished", - hookName: "post-commit", - exitCode: 0, - durationMs: 12, - }; -} - -function createActionFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "action_finished", - result: { - action: "commit_push", - branch: { status: "skipped_not_requested" }, - commit: { status: "created", commitSha: "abc123", subject: "Test commit" }, - push: { - status: "pushed", - branch: "feature/test", - upstreamBranch: "origin/feature/test", - }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Done", - description: "Action finished", - cta: { kind: "none" }, - }, - } satisfies GitRunStackedActionResult, - }; -} - -function createMockClient() { - const refreshDeferred = createDeferred(); - const pullDeferred = createDeferred(); - const switchRefDeferred = createDeferred(); - const createRefDeferred = createDeferred(); - const createWorktreeDeferred = createDeferred(); - const initDeferred = createDeferred(); - const runChangeRequestDeferred = createDeferred(); - let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null; - - const client: VcsActionClient = { - refreshStatus: vi.fn(() => refreshDeferred.promise), - pull: vi.fn(() => pullDeferred.promise), - switchRef: vi.fn(() => switchRefDeferred.promise), - createRef: vi.fn(() => createRefDeferred.promise), - createWorktree: vi.fn(() => createWorktreeDeferred.promise), - init: vi.fn(() => initDeferred.promise), - runChangeRequest: vi.fn((_, options) => { - runChangeRequestProgressListener = options?.onProgress ?? null; - return runChangeRequestDeferred.promise; - }), - }; - - return { - client, - refreshDeferred, - pullDeferred, - switchRefDeferred, - createRefDeferred, - createWorktreeDeferred, - initDeferred, - runChangeRequestDeferred, - emitProgress(event: GitActionProgressEvent) { - runChangeRequestProgressListener?.(event); - }, - }; -} - -describe("createVcsActionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("tracks refreshStatus progress and clears state on success", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.refreshStatus(TARGET, mock.client); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "refresh_status", - currentLabel: "Refreshing source control status", - error: null, - }); - - mock.refreshDeferred.resolve(BASE_STATUS); - - await expect(promise).resolves.toEqual(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("tracks runChangeRequest progress events", async () => { - const mock = createMockClient(); - const onProgress = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getActionId: () => "action-123", - }); - - const promise = manager.runChangeRequest( - TARGET, - { action: "commit_push", commitMessage: "Test commit" }, - { client: mock.client, gitStatus: BASE_STATUS, onProgress }, - ); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "run_change_request", - actionId: "action-123", - currentLabel: "Committing...", - error: null, - }); - - mock.emitProgress(createPhaseStartedEvent()); - expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing..."); - expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent()); - - mock.emitProgress(createHookStartedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Running post-commit...", - hookName: "post-commit", - isRunning: true, - }); - - mock.emitProgress(createHookOutputEvent()); - expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output"); - - mock.emitProgress(createHookFinishedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Committing...", - hookName: null, - lastOutputLine: null, - }); - - const result = createActionFinishedEvent().result; - mock.runChangeRequestDeferred.resolve(result); - - await expect(promise).resolves.toEqual(result); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("stores the error when an operation fails", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.pull(TARGET, mock.client); - - mock.pullDeferred.reject(new Error("Pull failed.")); - - await expect(promise).rejects.toThrow("Pull failed."); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: false, - operation: "pull", - currentLabel: null, - error: "Pull failed.", - }); - }); - - it("invalidates after successful mutations but not refreshStatus", async () => { - const mock = createMockClient(); - const onInvalidate = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - onInvalidate, - }); - - const refreshPromise = manager.refreshStatus(TARGET, mock.client); - mock.refreshDeferred.resolve(BASE_STATUS); - await expect(refreshPromise).resolves.toEqual(BASE_STATUS); - expect(onInvalidate).not.toHaveBeenCalled(); - - const pullPromise = manager.pull(TARGET, mock.client); - const pullResult: VcsPullResult = { - status: "skipped_up_to_date", - refName: "main", - upstreamRef: null, - }; - mock.pullDeferred.resolve(pullResult); - await expect(pullPromise).resolves.toEqual(pullResult); - expect(onInvalidate).toHaveBeenCalledWith(TARGET); - }); - - it("returns null when no client is available", async () => { - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull(); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); -}); diff --git a/packages/client-runtime/src/vcsActionState.ts b/packages/client-runtime/src/vcsActionState.ts deleted file mode 100644 index 5ff545b4596..00000000000 --- a/packages/client-runtime/src/vcsActionState.ts +++ /dev/null @@ -1,458 +0,0 @@ -import type { - GitActionProgressEvent, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStackedAction, - EnvironmentId, - VcsCreateRefInput, - VcsCreateRefResult, - VcsCreateWorktreeInput, - VcsCreateWorktreeResult, - VcsPullInput, - VcsPullResult, - VcsStatusResult, - VcsSwitchRefInput, - VcsSwitchRefResult, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { buildGitActionProgressStages } from "./gitActions.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export type VcsActionOperation = - | "refresh_status" - | "run_change_request" - | "pull" - | "switch_ref" - | "create_ref" - | "create_worktree" - | "init"; - -export interface VcsActionState { - readonly isRunning: boolean; - readonly operation: VcsActionOperation | null; - readonly actionId: string | null; - readonly action: GitStackedAction | null; - readonly currentLabel: string | null; - readonly currentPhaseLabel: string | null; - readonly hookName: string | null; - readonly lastOutputLine: string | null; - readonly phaseStartedAtMs: number | null; - readonly hookStartedAtMs: number | null; - readonly error: string | null; -} - -export interface VcsActionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsActionClient = Pick< - WsRpcClient["vcs"], - "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" -> & { - readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; -}; - -export const EMPTY_VCS_ACTION_STATE = Object.freeze({ - isRunning: false, - operation: null, - actionId: null, - action: null, - currentLabel: null, - currentPhaseLabel: null, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: null, - hookStartedAtMs: null, - error: null, -}); - -const knownVcsActionKeys = new Set(); -let nextGeneratedActionId = 0; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export const vcsActionStateAtom = Atom.family((key: string) => { - knownVcsActionKeys.add(key); - return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-action:${key}`), - ); -}); - -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-action:null"), -); - -export function getVcsActionTargetKey(target: VcsActionTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -export function applyVcsActionProgressEvent( - current: VcsActionState, - event: GitActionProgressEvent, -): VcsActionState { - const now = nowMs(); - - switch (event.kind) { - case "action_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "phase_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: event.label, - currentPhaseLabel: event.label, - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "hook_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: `Running ${event.hookName}...`, - hookName: event.hookName, - hookStartedAtMs: now, - lastOutputLine: null, - error: null, - }; - case "hook_output": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - lastOutputLine: event.text, - error: null, - }; - case "hook_finished": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: current.currentPhaseLabel, - hookName: null, - hookStartedAtMs: null, - lastOutputLine: null, - error: null, - }; - case "action_finished": - return { - ...current, - isRunning: false, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: null, - }; - case "action_failed": - return { - ...EMPTY_VCS_ACTION_STATE, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: event.message, - }; - } -} - -export interface VcsActionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null; - readonly getActionId?: () => string; - readonly onInvalidate?: (target: VcsActionTarget) => void | Promise; -} - -export function createVcsActionManager(config: VcsActionManagerConfig) { - function setState(targetKey: string, nextState: VcsActionState): void { - config.getRegistry().set(vcsActionStateAtom(targetKey), nextState); - } - - function startOperation( - targetKey: string, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly label: string; - }, - ): void { - setState(targetKey, { - isRunning: true, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - currentLabel: input.label, - currentPhaseLabel: input.label, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: nowMs(), - hookStartedAtMs: null, - error: null, - }); - } - - function finishOperation(targetKey: string): void { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - - function failOperation( - targetKey: string, - error: unknown, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - }, - ): void { - setState(targetKey, { - ...EMPTY_VCS_ACTION_STATE, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - error: error instanceof Error ? error.message : "Source control action failed.", - }); - } - - async function runOperation( - target: VcsActionTarget, - input: { - readonly operation: VcsActionOperation; - readonly label: string; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly client?: VcsActionClient | undefined; - readonly invalidateOnSuccess?: boolean; - readonly execute: (client: VcsActionClient) => Promise; - }, - ): Promise { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - - const resolved = input.client ?? config.getClient(target.environmentId); - if (!resolved) { - return null; - } - - startOperation(targetKey, input); - try { - const result = await input.execute(resolved); - finishOperation(targetKey); - if (input.invalidateOnSuccess ?? true) { - await config.onInvalidate?.(target); - } - return result; - } catch (error) { - failOperation(targetKey, error, input); - throw error; - } - } - - function getSnapshot(target: VcsActionTarget): VcsActionState { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_ACTION_STATE; - } - - return config.getRegistry().get(vcsActionStateAtom(targetKey)); - } - - async function refreshStatus( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly quiet?: boolean }, - ): Promise> | null> { - if (options?.quiet) { - if (target.environmentId === null || target.cwd === null) { - return null; - } - const resolved = client ?? config.getClient(target.environmentId); - return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; - } - - return runOperation(target, { - operation: "refresh_status", - label: "Refreshing source control status", - client, - invalidateOnSuccess: false, - execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), - }); - } - - async function pull( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "pull", - label: options?.label ?? "Pulling latest changes", - client, - execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput), - }); - } - - async function switchRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "switch_ref", - label: options?.label ?? "Switching branch", - client, - execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_ref", - label: options?.label ?? "Creating branch", - client, - execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createWorktree( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_worktree", - label: options?.label ?? "Creating worktree", - client, - execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), - }); - } - - async function init( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise> | null> { - return runOperation(target, { - operation: "init", - label: options?.label ?? "Initializing repository", - client, - execute: (resolved) => resolved.init({ cwd: target.cwd! }), - }); - } - - async function runChangeRequest( - target: VcsActionTarget, - input: Omit & { readonly actionId?: string }, - options?: { - readonly client?: VcsActionClient; - readonly gitStatus?: VcsStatusResult | null; - readonly onProgress?: (event: GitActionProgressEvent) => void; - }, - ): Promise { - const actionId = - input.actionId ?? - config.getActionId?.() ?? - `vcs-action-${nowMs()}-${++nextGeneratedActionId}`; - const targetKey = getVcsActionTargetKey(target); - - return runOperation(target, { - operation: "run_change_request", - label: - buildGitActionProgressStages({ - action: input.action, - hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), - hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, - featureBranch: input.featureBranch ?? false, - shouldPushBeforePr: - input.action === "create_pr" && - (!(options?.gitStatus?.hasUpstream ?? false) || - (options?.gitStatus?.aheadCount ?? 0) > 0), - })[0] ?? "Running source control action", - actionId, - action: input.action, - client: options?.client, - execute: async (resolved) => { - const result = await resolved.runChangeRequest( - { - cwd: target.cwd!, - actionId, - ...input, - }, - { - onProgress: (event) => { - if (targetKey !== null) { - const current = getSnapshot(target); - setState(targetKey, applyVcsActionProgressEvent(current, event)); - } - options?.onProgress?.(event); - }, - }, - ); - return result; - }, - }); - } - - function reset(target?: VcsActionTarget): void { - if (target) { - const targetKey = getVcsActionTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - return; - } - - for (const key of knownVcsActionKeys) { - setState(key, EMPTY_VCS_ACTION_STATE); - } - } - - return { - getSnapshot, - refreshStatus, - pull, - switchRef, - createRef, - createWorktree, - init, - runChangeRequest, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsRefState.test.ts b/packages/client-runtime/src/vcsRefState.test.ts deleted file mode 100644 index 3e58c0b5ac0..00000000000 --- a/packages/client-runtime/src/vcsRefState.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - createVcsRefManager, - EMPTY_VCS_REF_STATE, - vcsRefStateAtom, - type VcsRefClient, -} from "./vcsRefState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const noop = () => undefined; - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const FIRST_PAGE: VcsListRefsResult = { - refs: [ - { name: "main", current: true, isDefault: true, worktreePath: null }, - { name: "feature/a", current: false, isDefault: false, worktreePath: null }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: 2, - totalCount: 3, -}; - -const SECOND_PAGE: VcsListRefsResult = { - refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 3, -}; - -function createMockClient() { - const listRefs = vi.fn(async (input: Parameters[0]) => { - if (input.query === "feature") { - return { - ...FIRST_PAGE, - refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")), - nextCursor: null, - totalCount: 2, - } satisfies VcsListRefsResult; - } - - if (input.cursor === 2) { - return SECOND_PAGE; - } - - return FIRST_PAGE; - }); - - return { - client: { listRefs } satisfies VcsRefClient, - listRefs, - }; -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -describe("createVcsRefManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads the first page and stores it in atom state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.load(TARGET, mock.client, { limit: 100 }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: true, - error: null, - }); - - await expect(promise).resolves.toEqual(FIRST_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - }); - - it("loads the next page and appends refs", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - const next = await manager.loadNext(TARGET, mock.client); - - expect(next).toEqual({ - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }, - isPending: false, - error: null, - }); - }); - - it("keeps cached refs visible while refreshing", async () => { - const nextLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? FIRST_PAGE : nextLoad.promise; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - - const refresh = manager.load(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: true, - error: null, - }); - - nextLoad.resolve(SECOND_PAGE); - await expect(refresh).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: SECOND_PAGE, - isPending: false, - error: null, - }); - }); - - it("preserves loaded pages during first-page revalidation", async () => { - const refreshedFirstPage: VcsListRefsResult = { - ...FIRST_PAGE, - refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - nextCursor: 1, - totalCount: 3, - }; - let callCount = 0; - const listRefs = vi.fn((async (input) => { - callCount += 1; - if (input.cursor === 2) { - return SECOND_PAGE; - } - return callCount === 1 ? FIRST_PAGE : refreshedFirstPage; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - await manager.loadNext(TARGET, client); - const beforeRefresh = manager.getSnapshot(TARGET).data; - expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - - await manager.load(TARGET, client, { preserveLoadedRefs: true }); - - const afterRefresh = manager.getSnapshot(TARGET).data; - expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - expect(afterRefresh?.nextCursor).toBeNull(); - }); - - it("stores query-specific state independently", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const queriedTarget = { ...TARGET, query: "feature" } as const; - const queried = await manager.load(queriedTarget, mock.client); - - expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]); - expect(manager.getSnapshot(TARGET).data).toBeNull(); - expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([ - "feature/a", - ]); - }); - - it("returns cached data when no client is available", async () => { - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), { - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE); - }); - - it("resets state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates every query for a cwd scope", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - const queriedTarget = { ...TARGET, query: "feature" } as const; - - await manager.load(TARGET, mock.client); - await manager.load(queriedTarget, mock.client); - - manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd }); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates target in-flight loads before they can write stale data", async () => { - const firstLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? firstLoad.promise : SECOND_PAGE; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - const staleLoad = manager.load(TARGET, client); - manager.invalidate(TARGET); - const freshLoad = manager.load(TARGET, client); - - expect(listRefs).toHaveBeenCalledTimes(2); - - firstLoad.resolve(FIRST_PAGE); - await expect(staleLoad).resolves.toEqual(FIRST_PAGE); - await expect(freshLoad).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); - - it("watches refs with a ref-counted client-change subscription", async () => { - const mock = createMockClient(); - let listener: () => void = noop; - const unsubscribe = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return unsubscribe; - }, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - - listener(); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - - firstUnwatch(); - expect(unsubscribe).not.toHaveBeenCalled(); - secondUnwatch(); - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - it("skips watched refresh while cached refs are fresh", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - staleTimeMs: 60_000, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE); - }); - firstUnwatch(); - - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - secondUnwatch(); - }); - - it("swallows watched refresh failures after storing error state", async () => { - const refreshError = new Error("backend unavailable"); - const listRefs = vi.fn(async () => { - throw refreshError; - }); - const onBackgroundError = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => ({ listRefs }), - onBackgroundError, - }); - - manager.watch(TARGET); - await Promise.resolve(); - await Promise.resolve(); - - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: false, - error: "backend unavailable", - }); - expect(onBackgroundError).toHaveBeenCalledWith(refreshError); - }); - }); - - it("starts a new watched refresh when the client is replaced while a load is in flight", async () => { - const firstLoad = deferred(); - const secondLoad = deferred(); - const firstListRefs = vi.fn(() => firstLoad.promise); - const secondListRefs = vi.fn(() => secondLoad.promise); - const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient; - const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient; - let currentClient: VcsRefClient = firstClient; - let listener: () => void = noop; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => currentClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return noop; - }, - }); - - manager.watch(TARGET); - await Promise.resolve(); - expect(firstListRefs).toHaveBeenCalledTimes(1); - - currentClient = secondClient; - listener(); - await Promise.resolve(); - expect(secondListRefs).toHaveBeenCalledTimes(1); - - secondLoad.resolve(SECOND_PAGE); - await secondLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - - firstLoad.resolve(FIRST_PAGE); - await firstLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); -}); diff --git a/packages/client-runtime/src/vcsRefState.ts b/packages/client-runtime/src/vcsRefState.ts deleted file mode 100644 index e414a5f3de5..00000000000 --- a/packages/client-runtime/src/vcsRefState.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { - EnvironmentId, - VcsListRefsInput, - VcsListRefsResult, - VcsRef as ContractVcsRef, -} from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity"; - -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface VcsRefTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query?: string | null; -} - -export interface VcsRefScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export interface VcsRefState { - readonly data: VcsListRefsResult | null; - readonly isPending: boolean; - readonly error: string | null; -} - -export type VcsRef = ContractVcsRef; -export type VcsRefClient = Pick; - -export const EMPTY_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: false, - error: null, -}); - -const INITIAL_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: true, - error: null, -}); - -const knownVcsRefKeys = new Set(); - -export const vcsRefStateAtom = Atom.family((key: string) => { - knownVcsRefKeys.add(key); - return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`)); -}); - -export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-refs:null"), -); - -function normalizeQuery(query: string | null | undefined): string { - return query?.trim() ?? ""; -} - -export function getVcsRefTargetKey(target: VcsRefTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`; -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load refs."; -} - -function mergeRefs( - previous: ReadonlyArray, - next: ReadonlyArray, -): ReadonlyArray { - const merged = new Map(); - for (const branch of previous) { - merged.set(branch.name, branch); - } - for (const branch of next) { - merged.set(branch.name, branch); - } - return [...merged.values()]; -} - -export interface VcsRefManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly watchLimit?: number; - readonly staleTimeMs?: number; - readonly onBackgroundError?: (error: unknown) => void; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -const NOOP: () => void = () => undefined; - -export function createVcsRefManager(config: VcsRefManagerConfig) { - const inFlight = new Map< - string, - { - readonly client: VcsRefClient; - readonly promise: Promise; - } - >(); - const loadVersions = new Map(); - const watched = new Map(); - const lastLoadedAt = new Map(); - const refreshTargets = new Map(); - const watchLoadOptions = - config.watchLimit === undefined - ? undefined - : { limit: config.watchLimit, preserveLoadedRefs: true }; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: config.staleTimeMs ?? 0, - revalidateOnMount: true, - }), - Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`), - ), - ); - - function getLoadVersion(targetKey: string): number { - return loadVersions.get(targetKey) ?? 0; - } - - function bumpLoadVersion(targetKey: string): number { - const next = getLoadVersion(targetKey) + 1; - loadVersions.set(targetKey, next); - return next; - } - - function getSnapshot(target: VcsRefTarget): VcsRefState { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_REF_STATE; - } - return config.getRegistry().get(vcsRefStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: VcsRefState): void { - config.getRegistry().set(vcsRefStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null }, - ); - } - - function setData(targetKey: string, data: VcsListRefsResult): void { - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - setState(targetKey, { - data, - isPending: false, - error: null, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - isPending: false, - error: toErrorMessage(error), - }); - } - - async function load( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): Promise { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - refreshTargets.set(targetKey, target); - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - return getSnapshot(target).data; - } - - const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`; - const existing = inFlight.get(inFlightKey); - if (existing && existing.client === resolved) { - return existing.promise; - } - - markPending(targetKey); - const loadVersion = bumpLoadVersion(targetKey); - - const current = getSnapshot(target).data; - const request: VcsListRefsInput = { - cwd: target.cwd, - ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}), - ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }; - - const promise = resolved.listRefs(request).then( - (result) => { - const nextData = - options?.append && current - ? { - ...result, - refs: mergeRefs(current.refs, result.refs), - } - : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length - ? { - ...result, - refs: mergeRefs(result.refs, current.refs), - nextCursor: current.nextCursor, - totalCount: Math.max(result.totalCount, current.totalCount), - } - : result; - if (getLoadVersion(targetKey) === loadVersion) { - setData(targetKey, nextData); - } - return nextData; - }, - (error) => { - if (getLoadVersion(targetKey) === loadVersion) { - setError(targetKey, error); - } - throw error; - }, - ); - - inFlight.set(inFlightKey, { client: resolved, promise }); - try { - return await promise; - } finally { - if (inFlight.get(inFlightKey)?.promise === promise) { - inFlight.delete(inFlightKey); - } - } - } - - function loadInBackground( - target: VcsRefTarget, - client: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): void { - void load(target, client, options).catch((error: unknown) => { - config.onBackgroundError?.(error); - }); - } - - async function loadNext( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { readonly limit?: number }, - ): Promise { - const current = getSnapshot(target).data; - if (!current?.nextCursor && current?.nextCursor !== 0) { - return current ?? null; - } - - return load(target, client, { - cursor: current.nextCursor, - append: true, - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }); - } - - function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) { - refreshTargets.set(targetKey, target); - - if (client || config.staleTimeMs === undefined) { - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (resolved) { - loadInBackground(target, resolved, watchLoadOptions); - } - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - lastLoaded !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const result = config - .getRegistry() - .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult< - VcsListRefsResult | null, - unknown - >; - if (result._tag === "Failure") { - config.onBackgroundError?.(result.cause); - } - } - - function watch(target: VcsRefTarget, client?: VcsRefClient): () => void { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - refreshWatchedTarget(targetKey, target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: VcsRefClient | null = null; - const sync = () => { - const resolved = config.getClient(target.environmentId!); - if (!resolved) { - currentClient = null; - return; - } - if (currentClient === resolved) { - return; - } - - const hadClient = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined); - }; - - const unsubscribe = config.subscribeClientChanges(sync); - sync(); - teardown = unsubscribe; - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function invalidate(target?: VcsRefTarget): void { - if (target) { - const targetKey = getVcsRefTargetKey(target); - if (targetKey !== null) { - bumpLoadVersion(targetKey); - setState(targetKey, EMPTY_VCS_REF_STATE); - for (const key of inFlight.keys()) { - if (key.startsWith(`${targetKey}:`)) { - inFlight.delete(key); - } - } - } - return; - } - - for (const key of knownVcsRefKeys) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - inFlight.clear(); - } - - function invalidateScope(scope: VcsRefScope): void { - if (scope.environmentId === null || scope.cwd === null) { - return; - } - - const keyPrefix = `${scope.environmentId}:${scope.cwd}:`; - for (const key of knownVcsRefKeys) { - if (key.startsWith(keyPrefix)) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - } - - for (const key of inFlight.keys()) { - if (key.startsWith(keyPrefix)) { - inFlight.delete(key); - } - } - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - inFlight.clear(); - loadVersions.clear(); - lastLoadedAt.clear(); - refreshTargets.clear(); - invalidate(); - } - - return { - getSnapshot, - watch, - load, - loadNext, - invalidate, - invalidateScope, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsStatusState.test.ts b/packages/client-runtime/src/vcsStatusState.test.ts deleted file mode 100644 index c671cb49742..00000000000 --- a/packages/client-runtime/src/vcsStatusState.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type VcsStatusClient, - createVcsStatusManager, - getVcsStatusDataForTarget, -} from "./vcsStatusState.ts"; - -/* ─── Test helpers ──────────────────────────────────────────────────── */ - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/push-status", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createMockClient(): { - client: VcsStatusClient; - listeners: Set<(event: VcsStatusResult) => void>; - emit: (event: VcsStatusResult) => void; -} { - const listeners = new Set<(event: VcsStatusResult) => void>(); - const client: VcsStatusClient = { - refreshStatus: vi.fn(async (input: { cwd: string }) => ({ - ...BASE_STATUS, - refName: `${input.cwd}-refreshed`, - })), - onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(listeners, listener), - ), - }; - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) listener(event); - }, - }; -} - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; -const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; -const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; -const TARGET_KEY = "env-local:/repo"; -const PENDING = { - targetKey: TARGET_KEY, - data: null, - error: null, - cause: null, - isPending: true, -}; -const EMPTY = { - targetKey: null, - data: null, - error: null, - cause: null, - isPending: false, -}; - -/* ─── Tests ─────────────────────────────────────────────────────────── */ - -describe("createVcsStatusManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - describe("with explicit client (no reconnection)", () => { - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - manager.reset(); - }); - - it("shares one subscription per cwd and updates the snapshot", () => { - const { client, listeners, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseA = manager.watch(TARGET, client); - const releaseB = manager.watch(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - releaseA(); - expect(listeners.size).toBe(1); - - releaseB(); - expect(listeners.size).toBe(0); - }); - - it("refreshes via unary RPC without restarting the stream", async () => { - const { client, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - emit(BASE_STATUS); - - const refreshed = await manager.refresh(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); - - // Snapshot still reflects stream data, not the refresh response - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("keeps subscriptions isolated by environment when cwds match", () => { - const local = createMockClient(); - const remote = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseLocal = manager.watch(TARGET, local.client); - const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client); - - local.emit(BASE_STATUS); - remote.emit({ ...BASE_STATUS, refName: "remote-branch" }); - - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch"); - - releaseLocal(); - releaseRemote(); - }); - - it("rejects status data from a previous cwd during target transitions", () => { - const staleState = { - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }; - - expect(getVcsStatusDataForTarget(staleState, FRESH_TARGET)).toBeNull(); - expect(getVcsStatusDataForTarget(staleState, TARGET)).toBe(BASE_STATUS); - }); - - it("returns null from refresh when no client is available", async () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.refresh(TARGET)).resolves.toBeNull(); - }); - - it("returns empty state for null targets", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY); - }); - }); - - describe("with subscribeClientChanges (reconnection)", () => { - it("waits for a delayed client registration", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => (clients.has(envId) ? envId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - // Register the client - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) listener(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("resubscribes after client is removed and re-registered", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => - clients.get(envId) ? `identity:${envId}:${clients.size}` : null, - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - // Register first client and watch - const first = createMockClient(); - clients.set("env-local", first); - const release = manager.watch(TARGET); - - first.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - // Remove client - clients.delete("env-local"); - for (const listener of connectionListeners) listener(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - targetKey: TARGET_KEY, - data: BASE_STATUS, - error: null, - cause: null, - isPending: true, - }); - - // Register new client (different identity) - const second = createMockClient(); - clients.set("env-local", second); - for (const listener of connectionListeners) listener(); - - second.emit({ ...BASE_STATUS, refName: "reconnected-branch" }); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch"); - - release(); - }); - - it("cleans up connection listener on unwatch", () => { - const connectionListeners = new Set<() => void>(); - const mock = createMockClient(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getClientIdentity: () => "id", - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(connectionListeners.size).toBe(1); - - release(); - expect(connectionListeners.size).toBe(0); - expect(mock.listeners.size).toBe(0); - }); - }); - - describe("with getClient config (one-shot)", () => { - it("resolves client from config and subscribes", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => (envId === "env-local" ? mock.client : null), - }); - - const release = manager.watch(TARGET); - expect(mock.client.onStatus).toHaveBeenCalledOnce(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - release(); - expect(mock.listeners.size).toBe(0); - }); - - it("returns noop when client is not available", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - release(); // should not throw - }); - }); - - describe("reset", () => { - it("tears down all active subscriptions", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - manager.watch(TARGET); - manager.watch(FRESH_TARGET); - expect(mock.listeners.size).toBe(2); - - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); - }); -}); diff --git a/packages/client-runtime/src/vcsStatusState.ts b/packages/client-runtime/src/vcsStatusState.ts deleted file mode 100644 index 08c8f6227e7..00000000000 --- a/packages/client-runtime/src/vcsStatusState.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts"; -import type * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -/* ─── Types ─────────────────────────────────────────────────────────── */ - -export interface VcsStatusState { - readonly targetKey: string | null; - readonly data: VcsStatusResult | null; - readonly error: GitManagerServiceError | null; - readonly cause: Cause.Cause | null; - readonly isPending: boolean; -} - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsStatusClient = Pick; - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* ─── Constants ─────────────────────────────────────────────────────── */ - -const NOOP: () => void = () => undefined; - -export const EMPTY_VCS_STATUS_STATE = Object.freeze({ - targetKey: null, - data: null, - error: null, - cause: null, - isPending: false, -}); - -function initialVcsStatusState(targetKey: string): VcsStatusState { - return { - targetKey, - data: null, - error: null, - cause: null, - isPending: true, - }; -} - -/* ─── Atoms ─────────────────────────────────────────────────────────── */ - -const knownVcsStatusKeys = new Set(); - -export const vcsStatusStateAtom = Atom.family((key: string) => { - knownVcsStatusKeys.add(key); - return Atom.make(initialVcsStatusState(key)).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-status:${key}`), - ); -}); - -export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-status:null"), -); - -/* ─── Helpers ───────────────────────────────────────────────────────── */ - -export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -export function getVcsStatusDataForTarget( - state: VcsStatusState, - target: VcsStatusTarget, -): VcsStatusResult | null { - const targetKey = getVcsStatusTargetKey(target); - return targetKey !== null && state.targetKey === targetKey ? state.data : null; -} - -/* ─── Subscription manager ──────────────────────────────────────────── */ - -export interface VcsStatusManagerConfig { - /** - * Get the atom registry to read/write VCS status atoms. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** Resolve a VCS client for an environment. */ - readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null; - /** - * Optional: get a stable identity for the current client. - * Used to detect reconnections — when the identity changes the - * manager tears down the old `onStatus` stream and subscribes anew. - */ - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - /** - * Optional: subscribe to environment-connection changes. - * When provided the manager reacts to client appear / disappear / - * reconnect events instead of doing a one-shot resolution. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; -} - -const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export function createVcsStatusManager(config: VcsStatusManagerConfig) { - const watched = new Map(); - const refreshInFlight = new Map>(); - const lastRefreshAt = new Map(); - - /* ── Atom helpers ───────────────────────────────────────────────── */ - - function markPending(targetKey: string): void { - const atom = vcsStatusStateAtom(targetKey); - const current = config.getRegistry().get(atom); - const next: VcsStatusState = - current.data === null - ? initialVcsStatusState(targetKey) - : { ...current, error: null, cause: null, isPending: true }; - if ( - current.data === next.data && - current.error === next.error && - current.cause === next.cause && - current.isPending === next.isPending - ) { - return; - } - config.getRegistry().set(atom, next); - } - - function setData(targetKey: string, status: VcsStatusResult): void { - config.getRegistry().set(vcsStatusStateAtom(targetKey), { - targetKey, - data: status, - error: null, - cause: null, - isPending: false, - }); - } - - /* ── Core subscription ──────────────────────────────────────────── */ - - function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void { - markPending(targetKey); - return client.onStatus({ cwd }, (status) => setData(targetKey, status), { - onResubscribe: () => markPending(targetKey), - }); - } - - /* ── Dynamic subscription (handles reconnection) ────────────────── */ - - function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void { - const environmentId = target.environmentId!; - const cwd = target.cwd!; - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(environmentId); - const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) return; - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, cwd, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - /* ── Public API ─────────────────────────────────────────────────── */ - - /** - * Begin watching VCS status for `target`. - * - * Multiple watchers sharing the same `environmentId:cwd` key share - * one `onStatus` WS subscription (ref-counted). - * - * @param target The environment + cwd to watch. - * @param client Optional pre-resolved client — skips `getClient` - * lookup and reconnection handling. Useful in tests. - * @returns An unwatch function. - */ - function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - // Explicit client — direct subscription, no reconnection handling. - teardown = subscribeStream(targetKey, target.cwd, client); - } else if (config.subscribeClientChanges) { - // Dynamic client — subscribe to connection changes for reconnection. - teardown = createDynamicSubscription(targetKey, target); - } else { - // One-shot client resolution. - const resolved = config.getClient(target.environmentId); - if (!resolved) return NOOP; - teardown = subscribeStream(targetKey, target.cwd, resolved); - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) return; - - entry.refCount -= 1; - if (entry.refCount > 0) return; - - entry.teardown(); - watched.delete(targetKey); - } - - /** - * Trigger a one-shot `refreshStatus` RPC for a target. - * Debounced (1 s) and deduplicated (in-flight). - * The server-side refresh pushes a new event on the existing - * `onStatus` stream, so the subscription picks it up automatically. - */ - function refresh( - target: VcsStatusTarget, - client?: VcsStatusClient, - ): Promise { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.cwd === null) { - return Promise.resolve(null); - } - - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (!resolved) { - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) return existing; - - const requestedAt = nowMs(); - const last = lastRefreshAt.get(targetKey) ?? 0; - if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) { - return Promise.resolve(getSnapshot(target).data); - } - - lastRefreshAt.set(targetKey, requestedAt); - const promise = resolved - .refreshStatus({ cwd: target.cwd }) - .finally(() => refreshInFlight.delete(targetKey)); - refreshInFlight.set(targetKey, promise); - return promise; - } - - function getSnapshot(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null) return EMPTY_VCS_STATUS_STATE; - return config.getRegistry().get(vcsStatusStateAtom(targetKey)); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshInFlight.clear(); - lastRefreshAt.clear(); - for (const key of knownVcsStatusKeys) { - config.getRegistry().set(vcsStatusStateAtom(key), initialVcsStatusState(key)); - } - knownVcsStatusKeys.clear(); - } - - return { watch, refresh, getSnapshot, reset }; -} diff --git a/packages/client-runtime/src/wsRpcClient.test.ts b/packages/client-runtime/src/wsRpcClient.test.ts deleted file mode 100644 index 584fb958fba..00000000000 --- a/packages/client-runtime/src/wsRpcClient.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - VcsStatusLocalResult, - VcsStatusRemoteResult, - VcsStatusStreamEvent, -} from "@t3tools/contracts"; -import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("./wsTransport.ts", () => ({ - WsTransport: class WsTransport { - dispose = vi.fn(async () => undefined); - reconnect = vi.fn(async () => undefined); - request = vi.fn(); - requestStream = vi.fn(); - subscribe = vi.fn(() => () => undefined); - }, -})); - -import { createWsRpcClient } from "./wsRpcClient.ts"; -import type { WsTransport } from "./wsTransport.ts"; - -const baseLocalStatus: VcsStatusLocalResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, -}; - -const baseRemoteStatus: VcsStatusRemoteResult = { - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -describe("createWsRpcClient", () => { - it("runs beforeReconnect before awaiting transport.reconnect", async () => { - const order: string[] = []; - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - order.push("reconnect"); - }), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport, { - beforeReconnect: () => { - order.push("beforeReconnect"); - }, - }); - - await client.reconnect(); - expect(order).toEqual(["beforeReconnect", "reconnect"]); - }); - - it("delegates heartbeat freshness to the transport", () => { - const isHeartbeatFresh = vi.fn(() => true); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh, - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - - expect(client.isHeartbeatFresh()).toBe(true); - expect(isHeartbeatFresh).toHaveBeenCalledOnce(); - }); - - it("reduces vcs status stream events into flat status snapshots", () => { - const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { - for (const event of [ - { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - }, - { - _tag: "remoteUpdated", - remote: baseRemoteStatus, - }, - { - _tag: "localUpdated", - local: { - ...baseLocalStatus, - hasWorkingTreeChanges: true, - }, - }, - ] satisfies VcsStatusStreamEvent[]) { - listener(event as TValue); - } - return () => undefined; - }); - - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.vcs.onStatus({ cwd: "/repo" }, listener); - - expect(listener.mock.calls).toEqual([ - [ - { - ...baseLocalStatus, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - hasWorkingTreeChanges: true, - }, - ], - ]); - }); - - it("tags stream subscriptions for targeted resubscribe handling", () => { - const subscribe = vi.fn(() => () => undefined); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.terminal.onMetadata(listener); - client.vcs.onStatus({ cwd: "/repo" }, listener); - client.server.subscribeConfig(listener); - client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener); - - const subscribeCalls = subscribe.mock.calls as unknown as Array< - readonly [unknown, unknown, { readonly tag?: string }?] - >; - expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ - WS_METHODS.subscribeTerminalMetadata, - WS_METHODS.subscribeVcsStatus, - WS_METHODS.subscribeServerConfig, - ORCHESTRATION_WS_METHODS.subscribeThread, - ]); - }); -}); diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts deleted file mode 100644 index 18a6559f315..00000000000 --- a/packages/client-runtime/src/wsRpcClient.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { - type GitActionProgressEvent, - type GitRunStackedActionInput, - type GitRunStackedActionResult, - type LocalApi, - ORCHESTRATION_WS_METHODS, - type RelayClientInstallProgressEvent, - type RelayClientStatus, - type ServerSettingsPatch, - type VcsStatusResult, - type VcsStatusStreamEvent, - WS_METHODS, -} from "@t3tools/contracts"; -import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; - -import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts"; -import { WsTransport } from "./wsTransport.ts"; - -type RpcTag = keyof WsRpcProtocolClient & string; -type RpcMethod = WsRpcProtocolClient[TTag]; -type RpcInput = Parameters>[0]; - -interface StreamSubscriptionOptions { - readonly onResubscribe?: () => void; -} - -function subscriptionOptions( - options: StreamSubscriptionOptions | undefined, - tag: string, -): StreamSubscriptionOptions & { readonly tag: string } { - return { - ...options, - tag, - }; -} - -type RpcUnaryMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? (input: RpcInput) => Promise - : never; - -type RpcUnaryNoArgMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? () => Promise - : never; - -type RpcStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void - : never; - -type RpcInputStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? ( - input: RpcInput, - listener: (event: TEvent) => void, - options?: StreamSubscriptionOptions, - ) => () => void - : never; - -interface GitRunStackedActionOptions { - readonly onProgress?: (event: GitActionProgressEvent) => void; -} - -export interface WsRpcClient { - readonly dispose: () => Promise; - readonly reconnect: () => Promise; - readonly isHeartbeatFresh: () => boolean; - readonly terminal: { - readonly open: RpcUnaryMethod; - readonly attach: RpcInputStreamMethod; - readonly write: RpcUnaryMethod; - readonly resize: RpcUnaryMethod; - readonly clear: RpcUnaryMethod; - readonly restart: RpcUnaryMethod; - readonly close: RpcUnaryMethod; - readonly onEvent: RpcStreamMethod; - readonly onMetadata: RpcStreamMethod; - }; - readonly preview: { - readonly open: RpcUnaryMethod; - readonly navigate: RpcUnaryMethod; - readonly refresh: RpcUnaryMethod; - readonly close: RpcUnaryMethod; - readonly list: RpcUnaryMethod; - readonly reportStatus: RpcUnaryMethod; - readonly automation: { - readonly connect: RpcInputStreamMethod; - readonly respond: RpcUnaryMethod; - readonly reportOwner: RpcUnaryMethod; - readonly clearOwner: RpcUnaryMethod; - }; - readonly onEvent: RpcStreamMethod; - readonly subscribePorts: RpcStreamMethod; - }; - readonly projects: { - readonly listEntries: RpcUnaryMethod; - readonly readFile: RpcUnaryMethod; - readonly searchEntries: RpcUnaryMethod; - readonly writeFile: RpcUnaryMethod; - }; - readonly filesystem: { - readonly browse: RpcUnaryMethod; - }; - readonly assets: { - readonly createUrl: RpcUnaryMethod; - }; - readonly sourceControl: { - readonly lookupRepository: RpcUnaryMethod; - readonly cloneRepository: RpcUnaryMethod; - readonly publishRepository: RpcUnaryMethod; - }; - readonly shell: { - readonly openInEditor: (input: { - readonly cwd: Parameters[0]; - readonly editor: Parameters[1]; - }) => ReturnType; - }; - readonly vcs: { - readonly pull: RpcUnaryMethod; - readonly refreshStatus: RpcUnaryMethod; - readonly onStatus: ( - input: RpcInput, - listener: (status: VcsStatusResult) => void, - options?: StreamSubscriptionOptions, - ) => () => void; - readonly listRefs: RpcUnaryMethod; - readonly createWorktree: RpcUnaryMethod; - readonly removeWorktree: RpcUnaryMethod; - readonly createRef: RpcUnaryMethod; - readonly switchRef: RpcUnaryMethod; - readonly init: RpcUnaryMethod; - }; - readonly git: { - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Promise; - readonly resolvePullRequest: RpcUnaryMethod; - readonly preparePullRequestThread: RpcUnaryMethod< - typeof WS_METHODS.gitPreparePullRequestThread - >; - }; - readonly review: { - readonly getDiffPreview: RpcUnaryMethod; - }; - readonly server: { - readonly getConfig: RpcUnaryNoArgMethod; - readonly refreshProviders: ( - input?: RpcInput, - ) => ReturnType>; - readonly discoverSourceControl: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverDiscoverSourceControl - >; - readonly updateProvider: RpcUnaryMethod; - readonly upsertKeybinding: RpcUnaryMethod; - readonly removeKeybinding: RpcUnaryMethod; - readonly getSettings: RpcUnaryNoArgMethod; - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => ReturnType>; - readonly subscribeConfig: RpcStreamMethod; - readonly subscribeLifecycle: RpcStreamMethod; - readonly subscribeAuthAccess: RpcStreamMethod; - readonly getTraceDiagnostics: RpcUnaryNoArgMethod; - readonly getProcessDiagnostics: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverGetProcessDiagnostics - >; - readonly getProcessResourceHistory: RpcUnaryMethod< - typeof WS_METHODS.serverGetProcessResourceHistory - >; - readonly signalProcess: RpcUnaryMethod; - }; - readonly cloud: { - readonly getRelayClientStatus: RpcUnaryNoArgMethod; - readonly installRelayClient: ( - onProgress?: (event: RelayClientInstallProgressEvent) => void, - ) => Promise; - }; - readonly orchestration: { - readonly dispatchCommand: RpcUnaryMethod; - readonly getTurnDiff: RpcUnaryMethod; - readonly getFullThreadDiff: RpcUnaryMethod; - readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< - typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot - >; - readonly subscribeShell: RpcStreamMethod; - readonly subscribeThread: RpcInputStreamMethod; - }; -} - -export interface CreateWsRpcClientOptions { - /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */ - readonly beforeReconnect?: () => void; -} - -export function createWsRpcClient( - transport: WsTransport, - options?: CreateWsRpcClientOptions, -): WsRpcClient { - return { - dispose: () => transport.dispose(), - isHeartbeatFresh: () => transport.isHeartbeatFresh(), - reconnect: async () => { - options?.beforeReconnect?.(); - await transport.reconnect(); - }, - terminal: { - open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), - attach: (input, listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.terminalAttach](input), - listener, - subscriptionOptions(options, WS_METHODS.terminalAttach), - ), - write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), - resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), - clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), - restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), - close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), - onEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalEvents]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents), - ), - onMetadata: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalMetadata]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), - ), - }, - preview: { - open: (input) => transport.request((client) => client[WS_METHODS.previewOpen](input)), - navigate: (input) => transport.request((client) => client[WS_METHODS.previewNavigate](input)), - refresh: (input) => transport.request((client) => client[WS_METHODS.previewRefresh](input)), - close: (input) => transport.request((client) => client[WS_METHODS.previewClose](input)), - list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), - reportStatus: (input) => - transport.request((client) => client[WS_METHODS.previewReportStatus](input)), - automation: { - connect: (input, listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.previewAutomationConnect](input), - listener, - subscriptionOptions(options, WS_METHODS.previewAutomationConnect), - ), - respond: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), - reportOwner: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), - clearOwner: (input) => - transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), - }, - onEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribePreviewEvents]({}), - listener, - options, - ), - subscribePorts: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeDiscoveredLocalServers]({}), - listener, - options, - ), - }, - projects: { - listEntries: (input) => - transport.request((client) => client[WS_METHODS.projectsListEntries](input)), - readFile: (input) => - transport.request((client) => client[WS_METHODS.projectsReadFile](input)), - searchEntries: (input) => - transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), - writeFile: (input) => - transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), - }, - filesystem: { - browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), - }, - assets: { - createUrl: (input) => - transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)), - }, - sourceControl: { - lookupRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), - cloneRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), - publishRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), - }, - shell: { - openInEditor: (input) => - transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), - }, - vcs: { - pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), - refreshStatus: (input) => - transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), - onStatus: (input, listener, options) => { - let current: VcsStatusResult | null = null; - return transport.subscribe( - (client) => client[WS_METHODS.subscribeVcsStatus](input), - (event: VcsStatusStreamEvent) => { - current = applyGitStatusStreamEvent(current, event); - listener(current); - }, - subscriptionOptions(options, WS_METHODS.subscribeVcsStatus), - ); - }, - listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), - createWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), - removeWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), - createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), - switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), - init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), - }, - git: { - runStackedAction: async (input, options) => { - let result: GitRunStackedActionResult | null = null; - - await transport.requestStream( - (client) => client[WS_METHODS.gitRunStackedAction](input), - (event) => { - options?.onProgress?.(event); - if (event.kind === "action_finished") { - result = event.result; - } - }, - ); - - if (result) { - return result; - } - - throw new Error("Git action stream completed without a final result."); - }, - resolvePullRequest: (input) => - transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), - preparePullRequestThread: (input) => - transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), - }, - review: { - getDiffPreview: (input) => - transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)), - }, - server: { - getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), - refreshProviders: (input) => - transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), - discoverSourceControl: () => - transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), - updateProvider: (input) => - transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), - upsertKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), - removeKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), - getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), - updateSettings: (patch) => - transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), - subscribeConfig: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerConfig]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerConfig), - ), - subscribeLifecycle: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle), - ), - subscribeAuthAccess: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeAuthAccess]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeAuthAccess), - ), - getTraceDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})), - getProcessDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), - getProcessResourceHistory: (input) => - transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), - signalProcess: (input) => - transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), - }, - cloud: { - getRelayClientStatus: () => - transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), - installRelayClient: async (onProgress) => { - let installed: RelayClientStatus | null = null; - await transport.requestStream( - (client) => client[WS_METHODS.cloudInstallRelayClient]({}), - (event) => { - onProgress?.(event); - if (event.type === "complete") { - installed = event.status; - } - }, - ); - if (installed) { - return installed; - } - throw new Error("Relay client install stream completed without a final status."); - }, - }, - orchestration: { - dispatchCommand: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), - getTurnDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), - getFullThreadDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), - getArchivedShellSnapshot: () => - transport.request((client) => - client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), - ), - subscribeShell: (listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell), - ), - subscribeThread: (input, listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), - ), - }, - }; -} diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts deleted file mode 100644 index 869c07f8766..00000000000 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { WsRpcGroup } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; -import * as Socket from "effect/unstable/socket/Socket"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -export interface WsProtocolLifecycleHandlers { - readonly getConnectionLabel?: () => string | null; - readonly getVersionMismatchHint?: () => string | null; - readonly isCloseIntentional?: () => boolean; - readonly isActive?: () => boolean; - readonly onAttempt?: (socketUrl: string) => void; - readonly onOpen?: () => void; - readonly onHeartbeatPing?: () => void; - readonly onHeartbeatPong?: () => void; - readonly onHeartbeatTimeout?: () => void; - readonly onRequestStart?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestChunk?: (info: { - readonly id: string; - readonly tag: string; - readonly chunkCount: number; - }) => void; - readonly onRequestExit?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; - readonly onError?: (message: string) => void; - readonly onClose?: ( - details: { readonly code: number; readonly reason: string }, - context: { readonly intentional: boolean }, - ) => void; -} - -export interface WsRpcProtocolRequestTelemetry { - readonly onRequestSent?: (requestId: string, tag: string) => void; - readonly onRequestAcknowledged?: (requestId: string) => void; - readonly onClearTrackedRequests?: () => void; -} - -export interface WsRpcProtocolOptions { - /** Backoff configuration for reconnect retries. */ - readonly backoff?: ReconnectBackoffConfig; - /** - * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. - * Use for additive telemetry (connection state, clearing request trackers on disconnect). - */ - readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; - /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ - readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; -} - -export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); -type RpcClientFactory = typeof makeWsRpcProtocolClient; -export type WsRpcProtocolClient = - RpcClientFactory extends Effect.Effect ? Client : never; -export type WsRpcProtocolSocketUrlProvider = string | (() => Promise); - -function formatSocketErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -function resolveWsRpcSocketUrl(rawUrl: string): string { - const resolved = new URL(rawUrl); - if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { - throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); - } - - resolved.pathname = "/ws"; - return resolved.toString(); -} - -type ResolvedLifecycleHandlers = Required< - Pick< - WsProtocolLifecycleHandlers, - | "getConnectionLabel" - | "getVersionMismatchHint" - | "isCloseIntentional" - | "isActive" - | "onAttempt" - | "onOpen" - | "onHeartbeatPing" - | "onHeartbeatPong" - | "onHeartbeatTimeout" - | "onError" - | "onClose" - > ->; - -function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { - return { - onAttempt: () => undefined, - onOpen: () => undefined, - onHeartbeatPing: () => undefined, - onHeartbeatPong: () => undefined, - onHeartbeatTimeout: () => undefined, - onError: () => undefined, - onClose: () => undefined, - getConnectionLabel: () => null, - getVersionMismatchHint: () => null, - isCloseIntentional: () => false, - isActive: () => true, - }; -} - -function resolveLifecycleHandlers( - handlers: WsProtocolLifecycleHandlers | undefined, - telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, -): ResolvedLifecycleHandlers { - const defaults = defaultLifecycleHandlers(); - const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; - const isCloseIntentional = - handlers?.isCloseIntentional ?? - telemetryLifecycle?.isCloseIntentional ?? - defaults.isCloseIntentional; - - return { - getConnectionLabel: () => - handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, - getVersionMismatchHint: () => - handlers?.getVersionMismatchHint?.() ?? - telemetryLifecycle?.getVersionMismatchHint?.() ?? - null, - isActive, - isCloseIntentional, - onAttempt: (socketUrl) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onAttempt?.(socketUrl); - handlers?.onAttempt?.(socketUrl); - }, - onOpen: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onOpen?.(); - handlers?.onOpen?.(); - }, - onHeartbeatPing: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPing?.(); - handlers?.onHeartbeatPing?.(); - }, - onHeartbeatPong: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPong?.(); - handlers?.onHeartbeatPong?.(); - }, - onHeartbeatTimeout: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatTimeout?.(); - handlers?.onHeartbeatTimeout?.(); - }, - onError: (message) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onError?.(message); - handlers?.onError?.(message); - }, - onClose: (details, context) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onClose?.(details, context); - handlers?.onClose?.(details, context); - }, - }; -} - -export function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, - options?: WsRpcProtocolOptions, -) { - const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); - const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; - const requestTelemetry = options?.requestTelemetry; - const resolvedUrl = - typeof url === "function" - ? Effect.promise(() => url()).pipe( - Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), - Effect.tapError((error) => - Effect.sync(() => { - lifecycle.onError(formatSocketErrorMessage(error)); - }), - ), - Effect.orDie, - ) - : resolveWsRpcSocketUrl(url); - - const trackingWebSocketConstructorLayer = Layer.succeed( - Socket.WebSocketConstructor, - (socketUrl, protocols) => { - lifecycle.onAttempt(socketUrl); - const socket = new globalThis.WebSocket(socketUrl, protocols); - - socket.addEventListener( - "open", - () => { - lifecycle.onOpen(); - }, - { once: true }, - ); - socket.addEventListener( - "error", - () => { - lifecycle.onError("Unable to connect to the T3 server WebSocket."); - }, - { once: true }, - ); - socket.addEventListener("message", (event) => { - try { - const message = JSON.parse(String(event.data)) as { readonly _tag?: string }; - if (message._tag === "Pong") { - lifecycle.onHeartbeatPong(); - } - } catch { - // Ignore malformed messages here; the Effect RPC parser still owns protocol errors. - } - }); - socket.addEventListener( - "close", - (event) => { - lifecycle.onClose( - { - code: event.code, - reason: event.reason, - }, - { - intentional: lifecycle.isCloseIntentional(), - }, - ); - }, - { once: true }, - ); - - return socket; - }, - ); - const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( - Layer.provide(trackingWebSocketConstructorLayer), - ); - - const baseSchedule = - backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries); - const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => - Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)), - ); - const protocolLayer = Layer.effect( - RpcClient.Protocol, - Effect.map( - RpcClient.makeProtocolSocket({ - retryPolicy, - retryTransientErrors: true, - }), - (protocol) => ({ - ...protocol, - run: (clientId, writeResponse) => - protocol.run(clientId, (response) => { - if (response._tag === "Chunk" || response._tag === "Exit") { - requestTelemetry?.onRequestAcknowledged?.(response.requestId); - } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { - requestTelemetry?.onClearTrackedRequests?.(); - } - return writeResponse(response); - }), - send: (clientId, request, transferables) => { - if (request._tag === "Request") { - requestTelemetry?.onRequestSent?.(request.id, request.tag); - if (lifecycle.isActive()) { - handlers?.onRequestStart?.({ - id: request.id, - tag: request.tag, - stream: false, - }); - } - } - return protocol.send(clientId, request, transferables); - }, - }), - ), - ); - const connectionHooksLayer = Layer.succeed( - RpcClient.ConnectionHooks, - RpcClient.ConnectionHooks.of({ - onConnect: Effect.void, - onDisconnect: Effect.void, - }), - ); - - return protocolLayer.pipe( - Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), - ); -} diff --git a/packages/client-runtime/src/wsTransport.test.ts b/packages/client-runtime/src/wsTransport.test.ts deleted file mode 100644 index 72a698d2fcf..00000000000 --- a/packages/client-runtime/src/wsTransport.test.ts +++ /dev/null @@ -1,959 +0,0 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { WsTransport } from "./wsTransport.ts"; - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = performance.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (performance.now() - startedAt >= timeoutMs) { - throw error; - } - await Effect.runPromise(Effect.sleep(Duration.millis(10))); - } - } -} - -function createTransport(...args: ConstructorParameters): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -describe("WsTransport", () => { - it("normalizes root websocket urls to /ws and preserves query params", async () => { - const transport = createTransport("ws://localhost:3020/?token=secret-token"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); - await transport.dispose(); - }); - - it("uses an explicit secure websocket base url", async () => { - const transport = createTransport("wss://app.example.com"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://app.example.com/ws"); - await transport.dispose(); - }); - - it("uses an explicit insecure websocket base url for remote backends", async () => { - const transport = createTransport("ws://192.168.1.44:3773"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); - await transport.dispose(); - }); - - it("supports async websocket url providers", async () => { - const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic"); - await transport.dispose(); - }); - - it("invokes optional lifecycle handlers when the socket opens and closes", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - }); - - await transport.dispose(); - }); - - it("tracks heartbeat freshness from websocket pongs", async () => { - const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(transport.isHeartbeatFresh()).toBe(false); - - const socket = getSocket(); - socket.open(); - socket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - - expect(transport.isHeartbeatFresh()).toBe(true); - expect(transport.isHeartbeatFresh(500)).toBe(true); - - nowSpy.mockReturnValue(1_501); - expect(transport.isHeartbeatFresh(500)).toBe(false); - - await transport.dispose(); - }); - - it("clears heartbeat freshness when reconnecting", async () => { - vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - expect(transport.isHeartbeatFresh()).toBe(true); - - await transport.reconnect(); - - expect(transport.isHeartbeatFresh()).toBe(false); - - await transport.dispose(); - }); - - it("does not report an intentional dispose as a close", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - await transport.dispose(); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("ignores stale socket lifecycle events after reconnect starts a new session", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - firstSocket.close(1006, "stale close"); - - expect(onClose).not.toHaveBeenCalled(); - - await transport.dispose(); - }); - - it("reconnects the websocket session without disposing the transport", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("sends unary RPC requests and resolves successful exits", async () => { - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - _tag: string; - id: string; - payload: unknown; - tag: string; - }; - expect(requestMessage).toMatchObject({ - _tag: "Request", - tag: WS_METHODS.serverUpsertKeybinding, - payload: { - command: "terminal.toggle", - key: "ctrl+k", - }, - }); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("delivers stream chunks to subscribers", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; - expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); - - const welcomeEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/workspace", - projectName: "workspace", - }, - }; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [welcomeEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith(welcomeEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes stream listeners after the stream exits", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }, - ], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) - .find( - (message): message is { _tag: "Request"; id: string; tag: string } => - message._tag === "Request" && message.id !== firstRequest.id, - ); - if (!secondRequest) { - throw new Error("Expected a resubscribe request"); - } - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - const firstEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }; - - firstSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [firstEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(firstEvent); - }); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { - id: string; - tag: string; - }; - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).not.toHaveBeenCalled(); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not retry stream subscriptions after application-level failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBe(1); - }); - await Effect.runPromise(Effect.sleep(Duration.millis(50))); - - expect(attempts).toBe(1); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { - error: "Git command failed in GitCore.statusDetails", - }); - expect(warnSpy).not.toHaveBeenCalledWith( - "WebSocket RPC subscription disconnected", - expect.anything(), - ); - - unsubscribe(); - await transport.dispose(); - }); - - it("keeps retrying stream subscriptions after transport failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Socket is not connected")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBeGreaterThanOrEqual(2); - }); - - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "Socket is not connected", - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - - const unsubscribeA = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - const unsubscribeB = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "SocketCloseError: 1006", - }); - - unsubscribeA(); - unsubscribeB(); - await transport.dispose(); - }); - - it("streams finite request events without re-subscribing", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - const socket = getSocket(); - socket.open(); - - const requestPromise = transport.requestStream( - (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/repo", - action: "commit", - }), - listener, - ); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - const progressEvent = { - actionId: "action-1", - cwd: "/repo", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - } as const; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [progressEvent], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await expect(requestPromise).resolves.toBeUndefined(); - expect(listener).toHaveBeenCalledWith(progressEvent); - expect( - socket.sent.filter((message) => { - const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; - return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; - }), - ).toHaveLength(1); - await transport.dispose(); - }); - - it("closes the client scope on the transport runtime before disposing the runtime", async () => { - const callOrder: string[] = []; - let resolveClose!: () => void; - const closePromise = new Promise((resolve) => { - resolveClose = resolve; - }); - - const runtime = { - runPromise: vi.fn(async () => { - callOrder.push("close:start"); - await closePromise; - callOrder.push("close:done"); - return undefined; - }), - dispose: vi.fn(async () => { - callOrder.push("runtime:dispose"); - }), - }; - const transport = { - disposed: false, - session: { - clientScope: {} as never, - runtime, - }, - closeSession: ( - WsTransport.prototype as unknown as { - closeSession: (session: { - clientScope: unknown; - runtime: { dispose: () => Promise; runPromise: () => Promise }; - }) => Promise; - } - ).closeSession, - } as unknown as WsTransport; - - void WsTransport.prototype.dispose.call(transport); - - expect(runtime.runPromise).toHaveBeenCalledTimes(1); - expect(runtime.dispose).not.toHaveBeenCalled(); - expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); - - resolveClose(); - - await waitFor(() => { - expect(runtime.dispose).toHaveBeenCalledTimes(1); - }); - - expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); - }); -}); diff --git a/packages/client-runtime/src/wsTransport.ts b/packages/client-runtime/src/wsTransport.ts deleted file mode 100644 index a68b0aba469..00000000000 --- a/packages/client-runtime/src/wsTransport.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { RpcClient } from "effect/unstable/rpc"; - -import { isTransportConnectionErrorMessage } from "./transportError.ts"; -import { - createWsRpcProtocolLayer, - makeWsRpcProtocolClient, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolClient, - type WsRpcProtocolSocketUrlProvider, -} from "./wsRpcProtocol.ts"; - -export interface WsTransportOptions { - /** - * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer - * (for example a `Tracer` layer for OTLP). - */ - readonly tracingLayer?: Layer.Layer; - /** - * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}). - * The web app supplies its instrumented layer factory. - */ - readonly createProtocolLayer?: ( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) => Layer.Layer; - readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; - /** - * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. - */ - readonly onBeforeReconnect?: () => void; -} - -interface SubscribeOptions { - readonly retryDelay?: Duration.Input; - readonly onResubscribe?: () => void; - readonly tag?: string; -} - -const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250); -const NOOP: () => void = () => undefined; - -interface TransportSession { - readonly clientPromise: Promise; - readonly clientScope: Scope.Closeable; - readonly runtime: ManagedRuntime.ManagedRuntime; -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -export class WsTransport { - private readonly url: WsRpcProtocolSocketUrlProvider; - private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; - private readonly options: WsTransportOptions | undefined; - private disposed = false; - private hasReportedTransportDisconnect = false; - private intentionalCloseDepth = 0; - private nextSessionId = 0; - private activeSessionId = 0; - private lastHeartbeatPongAt: number | null = null; - private readonly streamRequestStartListeners = new Set< - (info: { readonly tag: string }) => void - >(); - private reconnectChain: Promise = Promise.resolve(); - private session: TransportSession; - - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - options?: WsTransportOptions, - ) { - this.url = url; - this.lifecycleHandlers = lifecycleHandlers; - this.options = options; - this.session = this.createSession(); - } - - async request( - execute: (client: WsRpcProtocolClient) => Effect.Effect, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - return await session.runtime.runPromise(Effect.suspend(() => execute(client))); - } - - async requestStream( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - await session.runtime.runPromise( - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - try { - listener(value); - } catch { - // Ignore listener errors so the stream can finish cleanly. - } - }), - ), - ); - } - - subscribe( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - options?: SubscribeOptions, - ): () => void { - if (this.disposed) { - return NOOP; - } - - let active = true; - let hasReceivedValue = false; - const retryDelayMs = Duration.toMillis( - Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY), - ); - let cancelCurrentStream: () => void = NOOP; - const onStreamRequestStart = (info: { readonly tag: string }) => { - if ( - !hasReceivedValue || - !active || - (options?.tag !== undefined && info.tag !== options.tag) - ) { - return; - } - - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - }; - this.streamRequestStartListeners.add(onStreamRequestStart); - - void (async () => { - for (;;) { - if (!active || this.disposed) { - return; - } - - const session = this.session; - try { - if (hasReceivedValue) { - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - } - const runningStream = this.runStreamOnSession( - session, - connect, - listener, - () => active, - () => { - this.hasReportedTransportDisconnect = false; - hasReceivedValue = true; - }, - ); - cancelCurrentStream = runningStream.cancel; - await runningStream.completed; - cancelCurrentStream = NOOP; - } catch (error) { - cancelCurrentStream = NOOP; - if (!active || this.disposed) { - return; - } - - // Skip retry if the session has already been replaced by a reconnect. - if (session !== this.session) { - continue; - } - - const formattedError = formatErrorMessage(error); - if (!isTransportConnectionErrorMessage(formattedError)) { - this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); - return; - } - - if (!this.hasReportedTransportDisconnect) { - this.logWarning("WebSocket RPC subscription disconnected", { - error: formattedError, - }); - } - this.hasReportedTransportDisconnect = true; - await sleep(retryDelayMs); - } - } - })(); - - return () => { - active = false; - this.streamRequestStartListeners.delete(onStreamRequestStart); - cancelCurrentStream(); - }; - } - - async reconnect() { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const reconnectOperation = this.reconnectChain.then(async () => { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - try { - this.options?.onBeforeReconnect?.(); - } catch { - // Ignore hook failures so reconnect can proceed. - } - - this.lastHeartbeatPongAt = null; - const previousSession = this.session; - this.session = this.createSession(); - await this.closeSession(previousSession); - }); - - this.reconnectChain = reconnectOperation.catch(() => undefined); - await reconnectOperation; - } - - isHeartbeatFresh(maxAgeMs = 15_000): boolean { - return ( - this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs - ); - } - - async dispose() { - if (this.disposed) { - return; - } - - this.disposed = true; - await this.closeSession(this.session); - } - - private closeSession(session: TransportSession) { - this.intentionalCloseDepth += 1; - return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { - this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); - session.runtime.dispose(); - }); - } - - private createSession(): TransportSession { - const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer; - const sessionId = this.nextSessionId + 1; - this.nextSessionId = sessionId; - this.activeSessionId = sessionId; - const lifecycleHandlers = this.lifecycleHandlers; - const protocolLayer = protocolFactory(this.url, { - ...lifecycleHandlers, - isActive: () => - !this.disposed && - this.activeSessionId === sessionId && - (lifecycleHandlers?.isActive?.() ?? true), - isCloseIntentional: () => - this.disposed || - this.intentionalCloseDepth > 0 || - lifecycleHandlers?.isCloseIntentional?.() === true, - onHeartbeatPong: () => { - this.lastHeartbeatPongAt = performance.now(); - lifecycleHandlers?.onHeartbeatPong?.(); - }, - onRequestStart: (info) => { - lifecycleHandlers?.onRequestStart?.(info); - if (!info.stream) { - return; - } - for (const listener of this.streamRequestStartListeners) { - listener({ tag: info.tag }); - } - }, - }); - const rootLayer = this.options?.tracingLayer - ? Layer.mergeAll(protocolLayer, this.options.tracingLayer) - : protocolLayer; - const runtime = ManagedRuntime.make(rootLayer); - const clientScope = runtime.runSync(Scope.make()); - return { - runtime, - clientScope, - clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), - }; - } - - private logWarning(message: string, metadata: { readonly error: string }) { - const logWarning = this.options?.logWarning; - if (logWarning) { - logWarning(message, metadata); - } else { - Effect.runSync(Effect.logWarning(message, metadata)); - } - } - - private runStreamOnSession( - session: TransportSession, - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - isActive: () => boolean, - markValueReceived: () => void, - ): { - readonly cancel: () => void; - readonly completed: Promise; - } { - let resolveCompleted!: () => void; - let rejectCompleted!: (error: unknown) => void; - const completed = new Promise((resolve, reject) => { - resolveCompleted = resolve; - rejectCompleted = reject; - }); - const cancel = session.runtime.runCallback( - Effect.promise(() => session.clientPromise).pipe( - Effect.flatMap((client) => - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - if (!isActive()) { - return; - } - - markValueReceived(); - try { - listener(value); - } catch { - // Ignore listener errors so the stream stays live. - } - }), - ), - ), - ), - { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - resolveCompleted(); - return; - } - - rejectCompleted(Cause.squash(exit.cause)); - }, - }, - ); - - return { - cancel, - completed, - }; - } -} - -function sleep(ms: number): Promise { - return Effect.runPromise(Effect.sleep(Duration.millis(ms))); -} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 73a306f847a..564a5990051 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -1,5 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, "include": ["src"] } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dce7c0709d3..03c06d2f81a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -899,13 +899,9 @@ export interface DesktopBridge { getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; - getSavedEnvironmentRegistry: () => Promise; - setSavedEnvironmentRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Promise; - getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; - setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; - removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + getConnectionCatalog?: () => Promise; + setConnectionCatalog?: (catalog: string) => Promise; + clearConnectionCatalog?: () => Promise; discoverSshHosts: () => Promise; ensureSshEnvironment: ( target: DesktopSshEnvironmentTarget, @@ -1049,13 +1045,6 @@ export interface LocalApi { persistence: { getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; - getSavedEnvironmentRegistry: () => Promise; - setSavedEnvironmentRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Promise; - getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; - setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; - removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; }; server: { getConfig: () => Promise; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 5c8cc1ad001..8b0068e730d 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -711,6 +711,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({ checkedAt: TrimmedNonEmptyString, descriptor: Schema.optional(ExecutionEnvironmentDescriptor), error: Schema.optional(TrimmedNonEmptyString), + traceId: Schema.optional(TrimmedNonEmptyString), }); export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8daad0b9b8d..3b3d46a0240 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -155,6 +155,10 @@ "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" }, + "./relayTracing": { + "types": "./src/relayTracing.ts", + "import": "./src/relayTracing.ts" + }, "./preview": { "types": "./src/preview.ts", "import": "./src/preview.ts" @@ -162,10 +166,6 @@ "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" - }, - "./relayTracing": { - "types": "./src/relayTracing.ts", - "import": "./src/relayTracing.ts" } }, "scripts": { diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index e165d944b56..93a1649b25b 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -81,9 +81,9 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); - it.effect("findAvailablePort falls back when preferred is occupied", () => + it.effect("findAvailablePort falls back when a wildcard listener occupies IPv4", () => Effect.acquireUseRelease( - openServer(), + openServer("0.0.0.0"), (server) => Effect.gen(function* () { const net = yield* NetService.NetService; diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 0a3c6283756..d7713a72612 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -28,40 +28,6 @@ const closeServer = (server: NodeNet.Server) => { } }; -const tryReservePort = (port: number): Effect.Effect => - Effect.callback((resume) => { - const server = NodeNet.createServer(); - let settled = false; - - const settle = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - - server.unref(); - - server.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); - }); - - server.listen(port, () => { - const address = server.address(); - const resolved = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => { - if (resolved > 0) { - settle(Effect.succeed(resolved)); - return; - } - settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); - }); - }); - - return Effect.sync(() => { - closeServer(server); - }); - }); - export interface NetServiceShape { /** * Returns true when a TCP server can bind to {host, port}. @@ -131,6 +97,53 @@ export const make = () => { }); }); + const hasListenerOnHost = (port: number, host: string): Effect.Effect => + Effect.callback((resume) => { + const socket = NodeNet.createConnection({ host, port }); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resume(Effect.succeed(value)); + }; + + socket.unref(); + socket.setTimeout(250); + socket.once("connect", () => { + settle(true); + }); + socket.once("error", () => { + settle(false); + }); + socket.once("timeout", () => { + settle(false); + }); + + return Effect.sync(() => { + socket.destroy(); + }); + }); + + const isPortAvailableOnLoopback = (port: number): Effect.Effect => + Effect.gen(function* () { + const hasListener = yield* Effect.zipWith( + hasListenerOnHost(port, "127.0.0.1"), + hasListenerOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (hasListener) { + return false; + } + + return yield* Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ); + }); + /** * Reserve an ephemeral loopback port and release it immediately. * Returns the reserved port number. @@ -169,15 +182,15 @@ export const make = () => { return { canListenOnHost, - isPortAvailableOnLoopback: (port) => - Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ), + isPortAvailableOnLoopback, reserveLoopbackPort, findAvailablePort: (preferred) => - Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + Effect.gen(function* () { + if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { + return preferred; + } + return yield* reserveLoopbackPort(); + }), } satisfies NetServiceShape; }; diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index bd00023e8fb..20d55a530e3 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -49,6 +49,7 @@ export function verifyRelayJwt(input: { readonly issuer: string; readonly audience: string; readonly nowEpochSeconds: number; + readonly maxTokenAge?: string | number; }): Effect.Effect { return Effect.tryPromise({ try: async () => { @@ -58,7 +59,7 @@ export function verifyRelayJwt(input: { typ: input.typ, issuer: input.issuer, audience: input.audience, - maxTokenAge: "5 minutes", + maxTokenAge: input.maxTokenAge ?? "5 minutes", clockTolerance: 60, currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 6005856d9d5..ecf035534ef 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -1,5 +1,7 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; @@ -39,6 +41,70 @@ export const withRelayClientTracing = ( ), ); +function traceSafeError(value: unknown): Error { + const message = + value instanceof Error + ? value.message + : typeof value === "object" && + value !== null && + "message" in value && + typeof value.message === "string" + ? value.message + : String(value); + const error = new Error(message); + if (value instanceof Error) { + error.name = value.name; + if (value.stack !== undefined) { + error.stack = value.stack; + } + } else if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" + ) { + error.name = value.name; + } + return error; +} + +function traceSafeExit(exit: Exit.Exit): Exit.Exit { + if (Exit.isSuccess(exit)) { + return exit; + } + return Exit.failCause( + Cause.fromReasons( + exit.cause.reasons.map((reason) => { + if (Cause.isFailReason(reason)) { + return Cause.makeFailReason(traceSafeError(reason.error)); + } + if (Cause.isDieReason(reason)) { + return Cause.makeDieReason(traceSafeError(reason.defect)); + } + return reason; + }), + ), + ); +} + +function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { + return Tracer.make({ + span(options) { + const span = delegate.span(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + try { + end(endTime, traceSafeExit(exit)); + } catch { + // Telemetry is best-effort and must never change application behavior. + } + }; + return span; + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +} + export function makeRelayClientTracingLayer( config: RelayClientTracingConfig | null, resource: RelayClientTracingResource, @@ -64,7 +130,8 @@ export function makeRelayClientTracingLayer( }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); - return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe( - Layer.provide(tracerLayer), - ); + return Layer.effect( + RelayClientTracer, + Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.map(Option.some)), + ).pipe(Layer.provide(tracerLayer)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5f5ec66ed..6d818936295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: expo-linking: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-network: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) @@ -514,9 +517,6 @@ importers: '@tanstack/react-pacer': specifier: ^0.19.4 version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-query': - specifier: ^5.90.0 - version: 5.100.14(react@19.2.6) '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -605,9 +605,6 @@ importers: msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - playwright: - specifier: ^1.58.2 - version: 1.60.0 tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -617,9 +614,6 @@ importers: vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - vitest-browser-react: - specifier: ^2.0.5 - version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) infra/relay: dependencies: @@ -4406,11 +4400,6 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-router@1.170.10': resolution: {integrity: sha512-gVmWYq0ucWr+OB97Nud0YhKa9NOipB7/QrWI7wRZJJWEL0qUS8WPqAs0vA1f3IBXZpXmf8xxzf/tl5cmo4tlmA==} engines: {node: '>=20.19'} @@ -4679,35 +4668,6 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@voidzero-dev/vite-plus-core@0.1.24': resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5372,10 +5332,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -6109,9 +6065,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -6131,10 +6084,6 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - expo-application@56.0.3: resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} peerDependencies: @@ -6286,6 +6235,12 @@ packages: peerDependencies: react-native: '*' + expo-network@56.0.5: + resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} + peerDependencies: + expo: '*' + react: '*' + expo-notifications@56.0.15: resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} peerDependencies: @@ -6586,11 +6541,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7940,10 +7890,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - obug@2.1.2: - resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} - engines: {node: '>=12.20.0'} - ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -8197,11 +8143,6 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} - engines: {node: '>=18'} - hasBin: true - plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -8916,9 +8857,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -9000,9 +8938,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -9200,10 +9135,6 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tldts-core@7.4.2: resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} @@ -9569,61 +9500,6 @@ packages: vite: optional: true - vitest-browser-react@2.2.0: - resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} - peerDependencies: - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - vitest: ^4.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -9767,11 +9643,6 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - widest-line@6.0.0: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} @@ -13662,11 +13533,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-query@5.100.14(react@19.2.6)': - dependencies: - '@tanstack/query-core': 5.100.14 - react: 19.2.6 - '@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/history': 1.162.0 @@ -13975,48 +13841,6 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) babel-plugin-react-compiler: 1.0.0 - '@vitest/expect@4.1.8': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - - '@vitest/pretty-format@4.1.8': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.8': - dependencies: - '@vitest/utils': 4.1.8 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.8': {} - - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -14806,8 +14630,6 @@ snapshots: ccount@2.0.1: {} - chai@6.2.2: {} - chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -15480,10 +15302,6 @@ snapshots: estree-walker@2.0.2: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -15496,8 +15314,6 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - expect-type@1.3.0: {} - expo-application@56.0.3(expo@56.0.8): dependencies: expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -15669,6 +15485,11 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-network@56.0.5(expo@56.0.8)(react@19.2.3): + dependencies: + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.3 + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) @@ -16092,9 +15913,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -17742,8 +17560,6 @@ snapshots: obug@2.1.1: {} - obug@2.1.2: {} - ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -18023,12 +17839,6 @@ snapshots: playwright-core@1.60.0: {} - playwright@1.60.0: - dependencies: - playwright-core: 1.60.0 - optionalDependencies: - fsevents: 2.3.2 - plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -18966,8 +18776,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -19039,8 +18847,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - stackback@0.0.2: {} - stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -19228,8 +19034,6 @@ snapshots: tinypool@2.1.0: {} - tinyrainbow@3.1.0: {} - tldts-core@7.4.2: {} tldts@7.4.2: @@ -19571,42 +19375,6 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - vitest: 4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - - vitest@4.1.8(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.12.4 - transitivePeerDependencies: - - msw - vlq@1.0.1: {} volar-service-css@0.0.70(@volar/language-service@2.4.28): @@ -19746,11 +19514,6 @@ snapshots: dependencies: isexe: 4.0.0 - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - widest-line@6.0.0: dependencies: string-width: 8.2.1 diff --git a/vite.config.ts b/vite.config.ts index 314ebf01e2e..1a8029b1656 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,12 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { fileURLToPath } from "node:url"; export default defineConfig({ resolve: { - tsconfigPaths: true, + alias: { + "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), + }, }, test: { environment: "node", @@ -93,6 +96,18 @@ export default defineConfig({ "typescript/require-array-sort-compare": "off", "typescript/restrict-template-expressions": "off", "typescript/unbound-method": "off", + "eslint/no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@t3tools/client-runtime", + message: + "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.", + }, + ], + }, + ], "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", From 1fcc57af136e00633c5d149c3d8564e98f7823c3 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Thu, 18 Jun 2026 23:20:53 -0400 Subject: [PATCH 005/142] fix(web): Remove saved environments atomically (#2917) --- .../settings/DesktopSavedEnvironments.test.ts | 20 ++++++++++++ .../src/settings/DesktopSavedEnvironments.ts | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 3456d7b7f3f..abf8394cdb4 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -272,6 +272,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* () { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 137a9a31dad..195992f0472 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -121,6 +121,9 @@ export interface DesktopSavedEnvironmentsShape { readonly setRegistry: ( records: readonly PersistedSavedEnvironmentRecord[], ) => Effect.Effect; + readonly removeEnvironment: ( + environmentId: string, + ) => Effect.Effect; readonly getSecret: ( environmentId: string, ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; @@ -293,6 +296,23 @@ export const layer = Layer.effect( ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); yield* writeDocument(preserveExistingSecrets(currentDocument, records)); }), + removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( + function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); + if (!document.records.some((record) => record.environmentId === environmentId)) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.filter((record) => record.environmentId !== environmentId), + }); + }, + ), getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { yield* Effect.annotateCurrentSpan({ environmentId }); const document = yield* readRegistryDocument( @@ -385,6 +405,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))), From 30034ecedc467ea2e00075f8cd2abd11ef8dd990 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 20:24:48 -0700 Subject: [PATCH 006/142] Add archived threads and mobile file viewer (#3155) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/desktop/src/preview/Manager.test.ts | 52 ++ apps/desktop/src/preview/Manager.ts | 101 ++- .../t3-markdown-text/ios/T3MarkdownText.mm | 112 +--- .../ios/T3MarkdownTextShadowNode.h | 15 +- .../ios/T3MarkdownTextShadowNode.mm | 35 +- .../src/NativeMarkdownBlock.ios.tsx | 71 +- .../src/NativeMarkdownSelectableText.ios.tsx | 123 ++-- .../src/SelectableMarkdownText.ios.tsx | 23 +- .../src/SelectableMarkdownText.types.ts | 5 +- .../t3-markdown-text/src/markdownLinks.ts | 72 +- .../src/nativeMarkdownText.ts | 10 + .../ios/T3ReviewDiffModule.swift | 4 + .../t3-review-diff/ios/T3ReviewDiffView.swift | 56 +- apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 5 +- apps/mobile/src/app/index.tsx | 177 +++-- apps/mobile/src/app/settings/_layout.tsx | 9 +- apps/mobile/src/app/settings/archive.tsx | 3 + apps/mobile/src/app/settings/index.tsx | 18 +- .../[environmentId]/[threadId]/_layout.tsx | 26 + .../[threadId]/files/[...path].tsx | 5 + .../[threadId]/files/index.tsx | 5 + apps/mobile/src/components/LoadingStrip.tsx | 93 +++ .../archive/ArchivedThreadsRouteScreen.tsx | 95 +++ .../archive/ArchivedThreadsScreen.tsx | 434 ++++++++++++ .../archive/archivedThreadList.test.ts | 144 ++++ .../features/archive/archivedThreadList.ts | 106 +++ .../archive/useArchivedThreadSnapshots.ts | 47 ++ .../features/diffs/nativeReviewDiffSurface.ts | 1 + .../features/files/FileMarkdownPreview.tsx | 166 +++++ .../src/features/files/FileTreeBrowser.tsx | 191 ++++++ .../src/features/files/SourceFileSurface.tsx | 252 +++++++ .../features/files/ThreadFilesRouteScreen.tsx | 631 ++++++++++++++++++ .../files/WorkspaceFileImagePreview.tsx | 118 ++++ .../files/WorkspaceFileWebPreview.tsx | 62 ++ .../src/features/files/filePath.test.ts | 43 ++ apps/mobile/src/features/files/filePath.ts | 116 ++++ .../src/features/files/fileTree.test.ts | 109 +++ apps/mobile/src/features/files/fileTree.ts | 220 ++++++ .../files/nativeSourceFileAdapter.test.ts | 46 ++ .../features/files/nativeSourceFileAdapter.ts | 65 ++ .../files/sourceHighlightingState.test.ts | 123 ++++ .../features/files/sourceHighlightingState.ts | 50 ++ .../files/workspace-file-image-cache.test.ts | 64 ++ .../files/workspace-file-image-cache.ts | 48 ++ .../features/files/workspaceFileAssetUrl.ts | 31 + apps/mobile/src/features/home/HomeHeader.tsx | 244 +++++++ apps/mobile/src/features/home/HomeScreen.tsx | 426 ++++++++---- .../src/features/home/homeThreadList.test.ts | 223 +++++++ .../src/features/home/homeThreadList.ts | 140 ++++ .../features/home/thread-swipe-actions.tsx | 238 +++++++ .../src/features/home/useThreadListActions.ts | 142 ++++ .../features/review/shikiReviewHighlighter.ts | 9 + .../features/threads/ThreadDetailScreen.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 301 +++------ .../features/threads/ThreadGitControls.tsx | 11 +- .../features/threads/ThreadRouteScreen.tsx | 2 + .../src/features/threads/thread-work-log.tsx | 261 ++++++++ apps/mobile/src/lib/markdownLinks.test.ts | 21 + .../mobile/src/lib/nativeMarkdownText.test.ts | 24 +- apps/mobile/src/lib/routes.test.ts | 45 ++ apps/mobile/src/lib/routes.ts | 53 ++ apps/mobile/src/lib/threadActivity.test.ts | 55 +- apps/mobile/src/lib/threadActivity.ts | 81 ++- apps/server/src/assets/AssetAccess.test.ts | 36 + apps/server/src/assets/AssetAccess.ts | 66 +- apps/web/src/AppRoot.test.tsx | 22 + apps/web/src/AppRoot.tsx | 19 + apps/web/src/browser/HostedBrowserWebview.tsx | 43 +- .../src/browser/desktopTabLifetime.test.ts | 45 ++ apps/web/src/browser/desktopTabLifetime.ts | 42 +- apps/web/src/components/ChatView.tsx | 41 +- .../preview/addBrowserSurface.test.ts | 52 ++ .../components/preview/addBrowserSurface.ts | 24 + .../preview/closePreviewSession.test.ts | 79 +++ .../components/preview/closePreviewSession.ts | 37 + .../preview/openPreviewSession.test.ts | 18 + .../components/preview/openPreviewSession.ts | 14 +- apps/web/src/lib/archivedThreadsState.ts | 45 +- apps/web/src/lib/threadSort.ts | 93 +-- apps/web/src/logicalProject.ts | 198 +----- apps/web/src/main.tsx | 10 +- apps/web/src/previewStateStore.test.ts | 31 +- apps/web/src/previewStateStore.ts | 44 +- apps/web/src/router.ts | 3 - packages/client-runtime/package.json | 8 + .../src/state/archivedThreads.ts | 44 ++ .../src/state/projectGrouping.ts | 183 +++++ .../client-runtime/src/state/threadSort.ts | 101 +++ packages/shared/package.json | 4 + packages/shared/src/filePreview.test.ts | 36 + packages/shared/src/filePreview.ts | 29 + pnpm-lock.yaml | 99 +-- 93 files changed, 6791 insertions(+), 1136 deletions(-) create mode 100644 apps/mobile/src/app/settings/archive.tsx create mode 100644 apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx create mode 100644 apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx create mode 100644 apps/mobile/src/components/LoadingStrip.tsx create mode 100644 apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx create mode 100644 apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx create mode 100644 apps/mobile/src/features/archive/archivedThreadList.test.ts create mode 100644 apps/mobile/src/features/archive/archivedThreadList.ts create mode 100644 apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts create mode 100644 apps/mobile/src/features/files/FileMarkdownPreview.tsx create mode 100644 apps/mobile/src/features/files/FileTreeBrowser.tsx create mode 100644 apps/mobile/src/features/files/SourceFileSurface.tsx create mode 100644 apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx create mode 100644 apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx create mode 100644 apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx create mode 100644 apps/mobile/src/features/files/filePath.test.ts create mode 100644 apps/mobile/src/features/files/filePath.ts create mode 100644 apps/mobile/src/features/files/fileTree.test.ts create mode 100644 apps/mobile/src/features/files/fileTree.ts create mode 100644 apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts create mode 100644 apps/mobile/src/features/files/nativeSourceFileAdapter.ts create mode 100644 apps/mobile/src/features/files/sourceHighlightingState.test.ts create mode 100644 apps/mobile/src/features/files/sourceHighlightingState.ts create mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.test.ts create mode 100644 apps/mobile/src/features/files/workspace-file-image-cache.ts create mode 100644 apps/mobile/src/features/files/workspaceFileAssetUrl.ts create mode 100644 apps/mobile/src/features/home/HomeHeader.tsx create mode 100644 apps/mobile/src/features/home/homeThreadList.test.ts create mode 100644 apps/mobile/src/features/home/homeThreadList.ts create mode 100644 apps/mobile/src/features/home/thread-swipe-actions.tsx create mode 100644 apps/mobile/src/features/home/useThreadListActions.ts create mode 100644 apps/mobile/src/features/threads/thread-work-log.tsx create mode 100644 apps/mobile/src/lib/routes.test.ts create mode 100644 apps/web/src/AppRoot.test.tsx create mode 100644 apps/web/src/AppRoot.tsx create mode 100644 apps/web/src/browser/desktopTabLifetime.test.ts create mode 100644 apps/web/src/components/preview/addBrowserSurface.test.ts create mode 100644 apps/web/src/components/preview/addBrowserSurface.ts create mode 100644 apps/web/src/components/preview/closePreviewSession.test.ts create mode 100644 apps/web/src/components/preview/closePreviewSession.ts create mode 100644 packages/client-runtime/src/state/projectGrouping.ts create mode 100644 packages/client-runtime/src/state/threadSort.ts create mode 100644 packages/shared/src/filePreview.test.ts create mode 100644 packages/shared/src/filePreview.ts diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d7252d3f8d9..687cdb75637 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -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..d4bed498021 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1195,26 +1195,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; 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/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 0c853aaec73..f47fb9d2452 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -101,6 +101,7 @@ "react-native-screens": "4.25.2", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", + "react-native-webview": "^13.16.1", "react-native-worklets": "0.8.3", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index db44e9904f8..968be6c14a8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -30,10 +30,11 @@ import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { const pathname = usePathname(); - const clerkRouteIsActive = pathname === "/settings/auth"; + const expandedSettingsRouteIsActive = + pathname === "/settings/archive" || pathname === "/settings/auth"; return ( - + ); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 3a846a13053..7f9962efc98 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,13 +1,33 @@ -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 { 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 ───────────────────────────────────────────────────── */ @@ -18,103 +38,82 @@ export default function HomeRouteScreen() { 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); - }, - onCancelButtonPress: () => { - setSearchQuery(""); - }, - 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/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx index 86831d885f1..2607c2cd1f1 100644 --- a/apps/mobile/src/app/settings/_layout.tsx +++ b/apps/mobile/src/app/settings/_layout.tsx @@ -14,7 +14,7 @@ export default function SettingsLayout() { const contentStyle = useResolveClassNames("bg-sheet"); const sheetBg = useThemeColor("--color-sheet"); const headerTint = useThemeColor("--color-foreground"); - const handleClerkRouteTransitionEnd = useCallback( + const handleExpandedRouteTransitionEnd = useCallback( (event: { data: { closing: boolean } }) => { 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/index.tsx b/apps/mobile/src/app/settings/index.tsx index 856264d602e..eae7c5fa33a 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() { /> + + @@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() { /> + + @@ -416,12 +420,20 @@ function AppSettingsSection() { ); } +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"); @@ -459,7 +471,9 @@ function SettingsRow(props: { if (props.href) { return ( - {content} + + {content} + ); } diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx index eb0ae8e074e..92e90920e2d 100644 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx @@ -55,6 +55,32 @@ export default function ThreadLayout() { headerStyle: headerBg, }} /> + + ; +} diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx new file mode 100644 index 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/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/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..ecdd9990186 --- /dev/null +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -0,0 +1,434 @@ +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/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 7bd53f67748..4a681663471 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -110,6 +110,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; diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx new file mode 100644 index 00000000000..1f6720cb7db --- /dev/null +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react"; +import { + Markdown, + type CustomRenderers, + type NodeStyleOverrides, + type PartialMarkdownTheme, +} from "react-native-nitro-markdown"; +import { Linking, ScrollView, Text as NativeText, View } from "react-native"; + +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 Linking.openURL(href); + } + }} + 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", + fontSize: 15, + lineHeight: 22, + }, + 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, + fontSize: 15, + lineHeight: 22, + 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(); + + 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..ff9577a4adb --- /dev/null +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -0,0 +1,191 @@ +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..5f3647d735f --- /dev/null +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -0,0 +1,252 @@ +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 { + buildNativeSourceRows, + buildNativeSourceTokens, + NATIVE_SOURCE_CONTENT_WIDTH, + NATIVE_SOURCE_ROW_HEIGHT, + NATIVE_SOURCE_STYLE, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; +import { sourceHighlightAtom } from "./sourceHighlightingState"; + +const SOURCE_LINE_HEIGHT = 24; +const SOURCE_LINE_NUMBER_WIDTH = 58; +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) + : ("500" 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..f730e4616ac --- /dev/null +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -0,0 +1,631 @@ +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, + Linking, + 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 { buildThreadFilesNavigation } from "../../lib/routes"; +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 Linking.openURL(props.externalPreviewUri); + } + }} + > + + + ) : 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..15d5b76717d --- /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..1628c4601d0 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -0,0 +1,62 @@ +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..937d3a1d3c8 --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildNativeSourceRows, + buildNativeSourceTokens, + nativeSourceRowId, +} from "./nativeSourceFileAdapter"; + +describe("nativeSourceFileAdapter", () => { + 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..19abc802146 --- /dev/null +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -0,0 +1,65 @@ +import type { + NativeReviewDiffRow, + NativeReviewDiffStyle, + NativeReviewDiffToken, +} from "../diffs/nativeReviewDiffSurface"; +import type { SourceHighlightTokens } from "./sourceHighlightingState"; + +export const NATIVE_SOURCE_ROW_HEIGHT = 24; +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: 58, + codePadding: 8, + codeFontSize: 13, + codeFontWeight: "medium", + lineNumberFontSize: 11, + lineNumberFontWeight: "medium", + emptyStateFontSize: 12, + 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..839053523a6 --- /dev/null +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -0,0 +1,244 @@ +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 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 b79d299f0cc..34de07ed9fd 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -2,12 +2,26 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import type { + 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 { 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 * as Arr from "effect/Array"; -import * as Order from "effect/Order"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; @@ -15,9 +29,14 @@ 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 { threadStatusTone } from "../threads/threadPresentation"; +import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { + THREAD_SWIPE_ACTIONS_WIDTH, + THREAD_SWIPE_SPRING, + ThreadSwipeActions, +} from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -27,26 +46,17 @@ interface HomeScreenProps { 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 onOpenEnvironments: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; } -interface ProjectGroup { - readonly key: string; - readonly project: EnvironmentProject; - 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: EnvironmentThreadShell): { bg: string; fg: string } { @@ -65,6 +75,33 @@ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } 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: WorkspaceState; @@ -133,6 +170,7 @@ function deriveEmptyState(props: { function ProjectGroupLabel(props: { readonly project: EnvironmentProject; + readonly title: string; readonly totalThreadCount: number; readonly isExpanded: boolean; readonly onToggleExpand: () => void; @@ -152,7 +190,7 @@ function ProjectGroupLabel(props: { style={{ letterSpacing: 0.5 }} numberOfLines={1} > - {props.project.title} + {props.title} {hiddenCount > 0 ? ( @@ -175,95 +213,172 @@ function ThreadRow(props: { 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; 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} - - + + - {/* Environment + branch */} - {subtitleParts.length > 0 ? ( - - + + - {subtitleParts.join(" · ")} + {props.thread.title} + + + + {tone.label} + + + + {timestamp} + + - ) : null} + + {subtitleParts.length > 0 ? ( + + + + {subtitleParts.join(" · ")} + + + ) : null} + - - + + ); } @@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: { export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); @@ -335,51 +451,50 @@ 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 || @@ -396,6 +511,7 @@ export function HomeScreen(props: HomeScreenProps) { showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" + onScrollBeginDrag={() => openSwipeableRef.current?.close()} className="flex-1" contentContainerStyle={{ paddingHorizontal: 16, @@ -418,8 +534,18 @@ export function HomeScreen(props: HomeScreenProps) { ) : null} - ) : !hasResults ? ( + ) : !hasResults && hasSearchQuery ? ( + ) : !hasResults && selectedEnvironmentLabel ? ( + + ) : !hasResults ? ( + ) : ( projectGroups.map((group) => { const isExpanded = expandedProjects.has(group.key); @@ -428,30 +554,52 @@ export function HomeScreen(props: HomeScreenProps) { : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); return ( - + toggleExpanded(group.key)} + project={group.representative} + title={group.title} + totalThreadCount={group.threads.length} /> - {visibleThreads.map((thread, i) => ( - props.onSelectThread(thread)} - isLast={i === visibleThreads.length - 1} - /> - ))} + {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} + /> + + ); + })} - + ); }) )} 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..faedaed7cee --- /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/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/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index fbfde4e787a..624e8fe14fe 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps { readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; + readonly threadCwd: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; @@ -309,6 +310,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread key={props.selectedThread.id} environmentId={props.environmentId} threadId={props.selectedThread.id} + workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} contentPresentation={props.contentPresentation} agentLabel={agentLabel} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9b9c4a71db1..55863557139 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -4,6 +4,7 @@ import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; import { SymbolView } from "expo-symbols"; +import { useRouter } from "expo-router"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Markdown, @@ -54,6 +55,7 @@ import { import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; import type { LayoutVariant } from "../../lib/layout"; +import { buildThreadFilesNavigation } from "../../lib/routes"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import { @@ -64,7 +66,9 @@ import { import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; import { relativeTime } from "../../lib/time"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { ThreadWorkLog } from "./thread-work-log"; import { useAssetUrl } from "../../state/assets"; +import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; const THREAD_FEED_END_THRESHOLD = 80; const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { @@ -83,6 +87,7 @@ function formatMessageTime(input: string): string { export interface ThreadFeedProps { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; + readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; readonly contentPresentation: ThreadContentPresentation; readonly agentLabel: string; @@ -120,32 +125,6 @@ function MessageAttachmentImage(props: { ); } -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 buildActivityRows( - activities: Extract["activities"], -) { - return activities.map((activity) => ({ - ...activity, - detail: compactActivityDetail(activity.detail), - })); -} - -const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; - const MARKDOWN_COLORS = { light: { body: "#111111", @@ -155,10 +134,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", }, @@ -170,10 +151,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", }, @@ -202,27 +185,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", }, }); @@ -250,7 +224,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); @@ -287,11 +261,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(() => { @@ -302,10 +274,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; @@ -394,28 +368,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} @@ -492,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 }) => ( 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; @@ -745,9 +712,10 @@ function renderFeedEntry( return ( @@ -757,6 +725,7 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} + onLinkPress={props.onMarkdownLinkPress} /> ) : null} {attachments.map((attachment) => { @@ -803,6 +772,7 @@ function renderFeedEntry( markdown={message.text} skills={props.skills} textStyle={styles.nativeTextStyle} + onLinkPress={props.onMarkdownLinkPress} /> ) : ( !(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} + /> ); } @@ -993,6 +857,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"); @@ -1003,6 +868,8 @@ function UserMessageContent(props: { markdown={props.text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ); } @@ -1042,6 +909,8 @@ function UserMessageContent(props: { markdown={text} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} + preserveSoftBreaks + onLinkPress={props.onLinkPress} /> ) : ( (null); const copyFeedbackTimeoutRef = useRef | null>(null); const scrollFrameRef = useRef(null); @@ -1283,6 +1153,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; @@ -1290,16 +1161,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), @@ -1490,11 +1401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, onToggleTurnFold, onPressImage, + onMarkdownLinkPress, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, skills: props.skills, }), [ @@ -1508,7 +1421,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + userBubbleMaxWidth, onCopyWorkRow, + onMarkdownLinkPress, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index a66f2082171..59b9af442ef 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -14,7 +14,7 @@ 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 { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; import { basename, getTerminalStatusLabel, @@ -69,6 +69,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; @@ -259,6 +260,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/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6d0bd2307ac..51f5832f50f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -420,6 +420,7 @@ export function ThreadRouteScreen() { gitStatus={gitStatus.data} gitOperationLabel={gitState.gitOperationLabel} canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} + canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)} projectScripts={selectedThreadProject?.scripts ?? []} terminalSessions={terminalMenuSessions} onOpenTerminal={handleOpenTerminal} @@ -452,6 +453,7 @@ export function ThreadRouteScreen() { activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} + threadCwd={selectedThreadCwd} selectedThreadQueueCount={composer.selectedThreadQueueCount} onOpenDrawer={handleOpenDrawer} onOpenConnectionEditor={handleOpenConnectionEditor} 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..244998eb336 --- /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/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/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/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 56d5663212c..3a33e2ee0f9 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -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/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 d6daa01d044..bef46e46e6e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -40,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; } @@ -60,6 +73,7 @@ interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; + toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -214,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) { @@ -225,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 { @@ -301,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; } @@ -365,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, @@ -377,6 +396,7 @@ function mergeDerivedWorkLogEntries( ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } @@ -480,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 { @@ -1226,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, @@ -1243,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/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 9d431140d06..29b1db25118 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -89,6 +89,42 @@ 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; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 659413f4748..ae5086e9735 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"; @@ -24,22 +30,14 @@ export const FALLBACK_PROJECT_FAVICON_SVG = ` 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,15 +159,24 @@ 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; } @@ -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/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/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 856654323c9..cdd33fa150d 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -9,7 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; 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 = 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/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/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 90a6dcdc338..ea63b96f699 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,12 +111,12 @@ import { useRightPanelStore, } from "../rightPanelStore"; import { - applyPreviewServerSnapshot, isPreviewSupportedInRuntime, - removePreviewSession, setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; @@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; - const activeTabId = activePreviewState.activeTabId; - if (activeTabId) { - useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); - return; - } - void (async () => { - const result = await openPreview({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId }, - }); - if (result._tag === "Failure") { - return; - } - applyPreviewServerSnapshot(activeThreadRef, result.value); - useRightPanelStore.getState().openBrowser(activeThreadRef, result.value.tabId); - })(); - }, [activePreviewState.activeTabId, activeThreadRef, openPreview]); + void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); + }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -2807,8 +2792,14 @@ function ChatViewContent(props: ChatViewProps) { search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), }); } - createBrowserSurface(); + const activeTabId = activePreviewState.activeTabId; + if (activeTabId) { + useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + } else { + createBrowserSurface(); + } }, [ + activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, diffOpen, @@ -2993,10 +2984,11 @@ function ChatViewContent(props: ChatViewProps) { for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { - removePreviewSession(activeThreadRef, surface.resourceId); - void closePreview({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, tabId: surface.resourceId }, + void closePreviewSession({ + closePreview, + snapshot: activePreviewState.sessions[surface.resourceId] ?? null, + tabId: surface.resourceId, + threadRef: activeThreadRef, }); } if (surface.kind === "terminal") { @@ -3020,6 +3012,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeThreadRef, + activePreviewState.sessions, closePreview, closeTerminalMutation, diffOpen, 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/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 81db47c4e9c..2e84fec5e68 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = { 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 (_input: PreviewOpenInput) => AsyncResult.success(snapshot)); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 1fd11bb587b..f86ea31a187 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -14,7 +14,7 @@ interface OpenPreviewSessionInput { readonly input: PreviewOpenInput; }) => Promise>; threadRef: ScopedThreadRef; - url: string; + url?: string; } export async function openPreviewSession( @@ -24,7 +24,7 @@ export async function openPreviewSession( environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, - url: input.url, + ...(input.url === undefined ? {} : { url: input.url }), }, }); if (result._tag === "Failure") { @@ -32,9 +32,11 @@ export async function openPreviewSession( } const snapshot = result.value; applyPreviewServerSnapshot(input.threadRef, snapshot); - rememberPreviewUrl( - input.threadRef, - snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, - ); + if (input.url !== undefined) { + rememberPreviewUrl( + input.threadRef, + snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, + ); + } return result; } diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index b39123e0eac..2d52383c02c 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -1,53 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { type ArchivedSnapshotEntry, + createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, - parseArchivedThreadsEnvironmentKey, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; -const archivedSnapshotsAtom = Atom.family((environmentKey: string) => - Atom.make((get) => { - const snapshots: ArchivedSnapshotEntry[] = []; - let error: string | null = null; - let isLoading = false; - - for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { - const result = get( - orchestrationEnvironment.archivedShellSnapshot({ - environmentId, - input: {}, - }), - ); - isLoading ||= result.waiting; - const snapshot = Option.getOrNull(AsyncResult.value(result)); - if (snapshot !== null) { - snapshots.push({ environmentId, snapshot }); - } - if (error === null && result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = - cause instanceof Error && cause.message.trim().length > 0 - ? cause.message - : "Failed to load archived threads."; - } - } - - return { - snapshots, - error, - isLoading, - }; - }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)), -); - function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ environmentId, @@ -55,6 +17,11 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) { }); } +const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: archivedSnapshotAtom, + labelPrefix: "web:archived-thread-snapshots", +}); + export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 9dc31cbbca5..ac3dea3aca5 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,86 +1,7 @@ -import type { ProjectId } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { Thread } from "../types"; - -export type ThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: ReadonlyArray>; -}; - -export function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - -function getFirstSortableTimestamp(...values: Array): number | null { - for (const value of values) { - const timestamp = toSortableTimestamp(value ?? undefined); - if (timestamp !== null) { - return timestamp; - } - } - - return null; -} - -function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -export function getThreadSortTimestamp( - thread: ThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return ( - getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY - ); - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreads & ThreadSortInput>( - threads: readonly T[], - sortOrder: SidebarThreadSortOrder, -): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - -export function getLatestThreadForProject< - T extends Pick & ThreadSortInput, ->(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { - return ( - sortThreads( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), - sortOrder, - )[0] ?? null - ); -} +export { + getLatestThreadForProject, + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "@t3tools/client-runtime/state/thread-sort"; diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index f9040dae976..8204222b3b0 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,184 +1,14 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; -import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { normalizeProjectPathForComparison } from "./lib/projectPaths"; - -export interface ProjectGroupingSettings { - sidebarProjectGroupingMode: SidebarProjectGroupingMode; - sidebarProjectGroupingOverrides: Record; -} - -export type ProjectGroupingMode = SidebarProjectGroupingMode; - -export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { - return { - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - }; -} - -function uniqueNonEmptyValues(values: ReadonlyArray): string[] { - const seen = new Set(); - const unique: string[] = []; - for (const value of values) { - const trimmed = value?.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; -} - -function deriveRepositoryRelativeProjectPath( - project: Pick, -): string | null { - const rootPath = project.repositoryIdentity?.rootPath?.trim(); - if (!rootPath) { - return null; - } - - const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); - const normalizedRootPath = normalizeProjectPathForComparison(rootPath); - if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { - return null; - } - - if (normalizedProjectPath === normalizedRootPath) { - return ""; - } - - const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; - const rootPrefix = `${normalizedRootPath}${separator}`; - if (!normalizedProjectPath.startsWith(rootPrefix)) { - return null; - } - - return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); -} - -export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { - return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; -} - -export function derivePhysicalProjectKey( - project: Pick, -): string { - return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); -} - -export function deriveProjectGroupingOverrideKey( - project: Pick, -): string { - return derivePhysicalProjectKey(project); -} - -// Key under which a project's manual sort order (projectOrder) is stored. -// Must stay aligned with the drag handlers and readers in `Sidebar`. -export function getProjectOrderKey( - project: Pick, -): string { - return derivePhysicalProjectKey(project); -} - -export function resolveProjectGroupingMode( - project: Pick, - settings: ProjectGroupingSettings, -): SidebarProjectGroupingMode { - return ( - settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? - settings.sidebarProjectGroupingMode - ); -} - -function deriveRepositoryScopedKey( - project: Pick, - groupingMode: SidebarProjectGroupingMode, -): string | null { - const canonicalKey = project.repositoryIdentity?.canonicalKey; - if (!canonicalKey) { - return null; - } - - if (groupingMode === "repository") { - return canonicalKey; - } - - const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); - if (relativeProjectPath === null) { - return canonicalKey; - } - - return relativeProjectPath.length === 0 - ? canonicalKey - : `${canonicalKey}::${relativeProjectPath}`; -} - -export function deriveLogicalProjectKey( - project: Pick< - EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" - >, - options?: { - groupingMode?: SidebarProjectGroupingMode; - }, -): string { - const groupingMode = options?.groupingMode ?? "repository"; - if (groupingMode === "separate") { - return derivePhysicalProjectKey(project); - } - - return ( - deriveRepositoryScopedKey(project, groupingMode) ?? - derivePhysicalProjectKey(project) ?? - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) - ); -} - -export function deriveLogicalProjectKeyFromSettings( - project: Pick< - EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" - >, - settings: ProjectGroupingSettings, -): string { - return deriveLogicalProjectKey(project, { - groupingMode: resolveProjectGroupingMode(project, settings), - }); -} - -export function deriveLogicalProjectKeyFromRef( - projectRef: ScopedProjectRef, - project: - | Pick - | null - | undefined, - options?: { - groupingMode?: SidebarProjectGroupingMode; - }, -): string { - return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); -} - -export function deriveProjectGroupLabel(input: { - representative: Pick; - members: ReadonlyArray>; -}): string { - const sharedDisplayNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.displayName), - ); - if (sharedDisplayNames.length === 1) { - return sharedDisplayNames[0]!; - } - - const sharedRepositoryNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.name), - ); - if (sharedRepositoryNames.length === 1) { - return sharedRepositoryNames[0]!; - } - - return input.representative.title; -} +export { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromRef, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + derivePhysicalProjectKeyFromPath, + deriveProjectGroupLabel, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + resolveProjectGroupingMode, + selectProjectGroupingSettings, + type ProjectGroupingMode, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8d56b687738..7d56d572f34 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; -import { RouterProvider } from "@tanstack/react-router"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@fontsource-variable/dm-sans/index.css"; @@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { AppRoot } from "./AppRoot"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME; const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -const app = ( - <> - - - -); +const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 458bfb5e5a6..d2bf2e7c260 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -7,10 +7,11 @@ import { applyPreviewDesktopState, applyPreviewServerEvent, applyPreviewServerSnapshot, + beginPreviewSessionClose, + cancelPreviewSessionClose, previewStateAtom, readThreadPreviewState, rememberPreviewUrl, - removePreviewSession, removePreviewThread, resetPreviewStateForTests, setActivePreviewTab, @@ -182,7 +183,7 @@ describe("previewStateStore (single-tab)", () => { applyPreviewServerSnapshot(ref, first); applyPreviewServerSnapshot(ref, second); - removePreviewSession(ref, second.tabId); + beginPreviewSessionClose(ref, second.tabId); const state = readThreadPreviewState(ref); expect(Object.keys(state.sessions)).toEqual([first.tabId]); @@ -193,7 +194,7 @@ describe("previewStateStore (single-tab)", () => { it("treats a late server close event after optimistic removal as a no-op", () => { const snapshot = makeSnapshot(); applyPreviewServerSnapshot(ref, snapshot); - removePreviewSession(ref, snapshot.tabId); + beginPreviewSessionClose(ref, snapshot.tabId); applyPreviewServerEvent(ref, { type: "closed", @@ -207,6 +208,30 @@ describe("previewStateStore (single-tab)", () => { expect(state.snapshot).toBeNull(); }); + it("does not resurrect an intentionally closed tab from a stale list snapshot", () => { + const snapshot = makeSnapshot(); + applyPreviewServerSnapshot(ref, snapshot); + beginPreviewSessionClose(ref, snapshot.tabId); + + applyPreviewServerSnapshot(ref, snapshot); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({}); + expect(state.snapshot).toBeNull(); + }); + + it("can restore a suppressed tab after a failed close", () => { + const snapshot = makeSnapshot(); + applyPreviewServerSnapshot(ref, snapshot); + beginPreviewSessionClose(ref, snapshot.tabId); + + cancelPreviewSessionClose(ref, snapshot, snapshot.tabId); + + const state = readThreadPreviewState(ref); + expect(state.sessions).toEqual({ [snapshot.tabId]: snapshot }); + expect(state.snapshot).toEqual(snapshot); + }); + it("closed event for a different tab is a no-op", () => { const snapshot = makeSnapshot({ tabId: "tab_a" }); applyPreviewServerEvent(ref, { diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 572d19750a6..7f8f8576130 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { export interface ThreadPreviewState { snapshot: PreviewSessionSnapshot | null; sessions: Record; + /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */ + suppressedTabIds: ReadonlySet; activeTabId: string | null; desktopOverlay: DesktopPreviewOverlay | null; desktopByTabId: Record; @@ -37,6 +39,7 @@ export interface ThreadPreviewState { const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ snapshot: null, sessions: {}, + suppressedTabIds: new Set(), activeTabId: null, desktopOverlay: null, desktopByTabId: {}, @@ -162,6 +165,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven case "opened": case "navigated": { const snapshot = event.snapshot; + if (current.suppressedTabIds.has(snapshot.tabId)) return current; const recentlySeenUrls = snapshot.navStatus._tag === "Idle" ? current.recentlySeenUrls @@ -221,6 +225,7 @@ export function applyPreviewServerSnapshot( desktopByTabId: {}, }; } + if (current.suppressedTabIds.has(snapshot.tabId)) return current; const existing = current.sessions[snapshot.tabId]; if (existing && existing.updatedAt > snapshot.updatedAt) return current; const recentlySeenUrls = @@ -255,8 +260,43 @@ export function applyPreviewDesktopState( }); } -export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void { - updateThreadPreviewState(ref, (current) => removeSession(current, tabId)); +export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void { + updateThreadPreviewState(ref, (current) => { + const suppressedTabIds = new Set(current.suppressedTabIds); + suppressedTabIds.add(tabId); + return { + ...removeSession(current, tabId), + suppressedTabIds, + }; + }); +} + +export function cancelPreviewSessionClose( + ref: ScopedThreadRef, + snapshot: PreviewSessionSnapshot | null, + tabId: string, +): void { + updateThreadPreviewState(ref, (current) => { + if (!current.suppressedTabIds.has(tabId)) return current; + const suppressedTabIds = new Set(current.suppressedTabIds); + suppressedTabIds.delete(tabId); + if (!snapshot) { + return { ...current, suppressedTabIds }; + } + const recentlySeenUrls = + snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + suppressedTabIds, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); } export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index f85b080f732..86ba9d69a17 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,7 +1,5 @@ -import { createElement } from "react"; import { createRouter, RouterHistory } from "@tanstack/react-router"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory) { @@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) { routeTree, history, context: {}, - Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children), }); } diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 87fba65e2e1..d9e19889721 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" }, + "./state/project-grouping": { + "types": "./src/state/projectGrouping.ts", + "default": "./src/state/projectGrouping.ts" + }, "./state/relay": { "types": "./src/state/relayDiscovery.ts", "default": "./src/state/relayDiscovery.ts" @@ -119,6 +123,10 @@ "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" }, + "./state/thread-sort": { + "types": "./src/state/threadSort.ts", + "default": "./src/state/threadSort.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 9fbb19f632e..7441d46cf32 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,13 +1,22 @@ import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; import * as Arr from "effect/Array"; +import * as Cause from "effect/Cause"; import { pipe } from "effect/Function"; +import * as Option from "effect/Option"; import * as Order from "effect/Order"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; export interface ArchivedSnapshotEntry { readonly environmentId: EnvironmentId; readonly snapshot: OrchestrationShellSnapshot; } +export interface ArchivedThreadSnapshotsState { + readonly snapshots: ReadonlyArray; + readonly error: string | null; + readonly isLoading: boolean; +} + const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; const environmentIdOrder = Order.String as Order.Order; @@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray EnvironmentId.make(environmentId)), ); } + +export function createArchivedThreadSnapshotsAtomFamily(options: { + readonly getSnapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + readonly labelPrefix: string; +}) { + return Atom.family((environmentKey: string) => + Atom.make((get): ArchivedThreadSnapshotsState => { + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + let isLoading = false; + + for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { + const result = get(options.getSnapshotAtom(environmentId)); + isLoading ||= result.waiting; + + const snapshot = Option.getOrNull(AsyncResult.value(result)); + if (snapshot !== null) { + snapshots.push({ environmentId, snapshot }); + } + + if (error === null && result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = + cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load archived threads."; + } + } + + return { snapshots, error, isLoading }; + }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), + ); +} diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts new file mode 100644 index 00000000000..549942be277 --- /dev/null +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -0,0 +1,183 @@ +import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; + +import type { EnvironmentProject } from "./models.ts"; +import { normalizeProjectPathForComparison } from "./projects.ts"; + +export interface ProjectGroupingSettings { + readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; + readonly sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + }; +} + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey( + project: Pick, +): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function getProjectOrderKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + +export function deriveLogicalProjectKey( + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, + options?: { + readonly groupingMode?: SidebarProjectGroupingMode; + }, +): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + + return ( + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) + ); +} + +export function deriveLogicalProjectKeyFromSettings( + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + +export function deriveLogicalProjectKeyFromRef( + projectRef: ScopedProjectRef, + project: + | Pick + | null + | undefined, + options?: { + readonly groupingMode?: SidebarProjectGroupingMode; + }, +): string { + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + readonly representative: Pick; + readonly members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.title; +} diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts new file mode 100644 index 00000000000..4da184962e6 --- /dev/null +++ b/packages/client-runtime/src/state/threadSort.ts @@ -0,0 +1,101 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +export interface ThreadSortInput { + readonly createdAt: string; + readonly updatedAt: string; + readonly latestUserMessageAt?: string | null; + readonly messages?: ReadonlyArray<{ + readonly createdAt: string; + readonly role: string; + }>; +} + +export function toSortableTimestamp(iso: string | undefined): number | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : null; +} + +function getFirstSortableTimestamp(...values: Array): number | null { + for (const value of values) { + const timestamp = toSortableTimestamp(value ?? undefined); + if (timestamp !== null) { + return timestamp; + } + } + + return null; +} + +function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + + let latestUserMessageTimestamp: number | null = null; + + for (const message of thread.messages ?? []) { + if (message.role !== "user") continue; + const messageTimestamp = toSortableTimestamp(message.createdAt); + if (messageTimestamp === null) continue; + latestUserMessageTimestamp = + latestUserMessageTimestamp === null + ? messageTimestamp + : Math.max(latestUserMessageTimestamp, messageTimestamp); + } + + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } + + return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; +} + +export function getThreadSortTimestamp( + thread: ThreadSortInput, + sortOrder: SidebarThreadSortOrder | Exclude, +): number { + if (sortOrder === "created_at") { + return ( + getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY + ); + } + return getLatestUserMessageTimestamp(thread); +} + +export function sortThreads( + threads: readonly T[], + sortOrder: SidebarThreadSortOrder, +): T[] { + return Arr.sort( + threads, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + id: Order.flip(Order.String), + }), + (thread: T) => ({ + timestamp: getThreadSortTimestamp(thread, sortOrder), + id: thread.id, + }), + ), + ); +} + +export function getLatestThreadForProject< + T extends { + readonly id: string; + readonly projectId: ProjectId; + readonly archivedAt: string | null; + } & ThreadSortInput, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + return ( + sortThreads( + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + sortOrder, + )[0] ?? null + ); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 3b3d46a0240..23705178bef 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -163,6 +163,10 @@ "types": "./src/preview.ts", "import": "./src/preview.ts" }, + "./filePreview": { + "types": "./src/filePreview.ts", + "import": "./src/filePreview.ts" + }, "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts new file mode 100644 index 00000000000..eb8b7d1e892 --- /dev/null +++ b/packages/shared/src/filePreview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, + isWorkspacePreviewEntryPath, +} from "./filePreview.ts"; + +describe("workspace file previews", () => { + it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + "recognizes browser preview path %s", + (path) => { + expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); + expect(isWorkspacePreviewEntryPath(path)).toBe(true); + }, + ); + + it.each([ + "icon.png", + "photo.JPEG", + "animation.gif", + "vector.svg#mark", + "texture.webp", + "image.avif", + ])("recognizes image preview path %s", (path) => { + expect(isWorkspaceImagePreviewPath(path)).toBe(true); + expect(isWorkspacePreviewEntryPath(path)).toBe(true); + }); + + it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( + "rejects non-preview path %s", + (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }, + ); +}); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts new file mode 100644 index 00000000000..c9d15e14c3b --- /dev/null +++ b/packages/shared/src/filePreview.ts @@ -0,0 +1,29 @@ +export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; + +export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ + ".avif", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".webp", +] as const; + +function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { + const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; + return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); +} + +export function isWorkspaceBrowserPreviewPath(path: string): boolean { + return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS); +} + +export function isWorkspaceImagePreviewPath(path: string): boolean { + return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); +} + +export function isWorkspacePreviewEntryPath(path: string): boolean { + return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d818936295..9b9312da667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,7 +247,7 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.15 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) @@ -362,6 +362,9 @@ importers: react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: + specifier: ^13.16.1 + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -8453,6 +8456,12 @@ packages: peerDependencies: react-native: '*' + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets@0.8.3: resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} peerDependencies: @@ -10784,7 +10793,7 @@ snapshots: '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -11396,7 +11405,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11494,7 +11503,7 @@ snapshots: '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -11561,7 +11570,7 @@ snapshots: dependencies: '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 @@ -11593,7 +11602,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11615,7 +11624,7 @@ snapshots: dependencies: '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -11693,7 +11702,7 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 @@ -11717,7 +11726,7 @@ snapshots: '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -14450,7 +14459,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' @@ -15316,12 +15325,12 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15346,14 +15355,14 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15361,25 +15370,25 @@ snapshots: expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) @@ -15391,18 +15400,18 @@ snapshots: expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15410,40 +15419,40 @@ snapshots: expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): @@ -15458,7 +15467,7 @@ snapshots: expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15487,7 +15496,7 @@ snapshots: expo-network@56.0.5(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): @@ -15495,7 +15504,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.8) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 @@ -15506,7 +15515,7 @@ snapshots: expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15525,7 +15534,7 @@ snapshots: color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -15561,7 +15570,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -15569,7 +15578,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15580,7 +15589,7 @@ snapshots: expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -15588,7 +15597,7 @@ snapshots: expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: @@ -15598,7 +15607,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15617,14 +15626,14 @@ snapshots: expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): dependencies: '@expo/plist': 0.7.0 '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15635,7 +15644,7 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -15665,6 +15674,7 @@ snapshots: '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -18139,6 +18149,13 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 From 52a24c890265967aa31fa03c4339210022ac403d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 20:39:52 -0700 Subject: [PATCH 007/142] Add origin-based worktree bootstrap option (#3157) --- apps/server/src/git/GitWorkflowService.ts | 20 ++++++ apps/server/src/server.test.ts | 50 +++++++++++++-- apps/server/src/vcs/GitVcsDriver.ts | 20 ++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 63 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 34 ++++++++++ apps/server/src/ws.ts | 15 ++++- apps/web/src/components/BranchToolbar.tsx | 6 ++ .../BranchToolbarBranchSelector.tsx | 38 ++++++++++- apps/web/src/components/ChatView.tsx | 35 +++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 3 + apps/web/src/components/Sidebar.logic.ts | 3 + apps/web/src/components/Sidebar.tsx | 4 ++ .../components/settings/SettingsPanels.tsx | 43 ++++++++++++- apps/web/src/composerDraftStore.test.ts | 15 +++++ apps/web/src/composerDraftStore.ts | 27 ++++++++ apps/web/src/hooks/useHandleNewThread.ts | 34 ++++++++-- apps/web/src/lib/chatThreadActions.test.ts | 44 +++++++++++++ apps/web/src/lib/chatThreadActions.ts | 12 ++++ packages/contracts/src/orchestration.test.ts | 2 + packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/settings.test.ts | 12 ++++ packages/contracts/src/settings.ts | 4 ++ 22 files changed, 472 insertions(+), 13 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5fce28922fd..0af4847f4ac 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -61,6 +61,18 @@ export interface GitWorkflowServiceShape { 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, @@ -295,6 +307,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)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 77a4dbde25f..205833289ea 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5918,6 +5918,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,13 +5937,33 @@ 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", - }, + Effect.sync(() => { + bootstrapGitOperations.push("create-worktree"); + return { + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }; }), ); const runForThread = vi.fn( @@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + fetchRemote, + resolveRemoteTrackingCommit, createWorktree, }, vcsStatusBroadcaster: { @@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + startFromOrigin: true, }, runSetupScript: true, }, @@ -6031,10 +6055,24 @@ 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", 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, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 66a5157ae83..ff0d644901d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.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; @@ -217,6 +233,10 @@ export interface GitVcsDriverShape { ) => 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; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f4b2fe4d914..41f5d595f0a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -442,6 +442,69 @@ 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", + }); + + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"), + null, + ); + }), + ); + 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 69550e0e7e5..5c24072052d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const fetchRemote: GitVcsDriver.GitVcsDriverShape["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.GitVcsDriverShape["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.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { @@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchPullRequestBranch, ensureRemote, resolvePrimaryRemoteName, + fetchRemote, + resolveRemoteTrackingCommit, fetchRemoteBranch, fetchRemoteTrackingBranch, setBranchUpstream, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84f..1ad37e7c49b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -694,9 +694,22 @@ 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, path: null, }); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d9b0989b684..03f24dac8e9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -45,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; @@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, + startFromOrigin, + onStartFromOriginChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -279,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 fd2c2b8c250..f8a2e1a6fcd 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -5,11 +5,12 @@ import { } 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, @@ -37,6 +38,8 @@ import { shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Combobox, ComboboxEmpty, @@ -58,6 +61,8 @@ 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; } @@ -90,9 +95,12 @@ 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, @@ -674,6 +682,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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ea63b96f699..a1ef90c4309 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -1129,6 +1130,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, {}, @@ -3330,6 +3335,12 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + const startFromOrigin = isLocalDraftThread + ? (draftThread?.startFromOrigin ?? false) + : canOverrideServerThreadEnvMode + ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? + settings.newWorktreesStartFromOrigin) + : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -3892,6 +3903,7 @@ function ChatViewContent(props: ChatViewProps) { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, } @@ -4577,6 +4589,10 @@ function ChatViewContent(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: mode, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } @@ -4587,12 +4603,29 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, + settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadStartFromOriginByThreadId((current) => + current[activeThread.id] === nextStartFromOrigin + ? current + : { ...current, [activeThread.id]: nextStartFromOrigin }, + ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + startFromOrigin: nextStartFromOrigin, + }); + } + }; + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -4926,6 +4959,8 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode ? { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 61fae76f8ef..b1c29888f9b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -237,6 +237,7 @@ describe("resolveSidebarNewThreadSeedContext", () => { branch: "feature/draft", worktreePath: "/repo/.t3/worktrees/draft", envMode: "worktree", + startFromOrigin: true, }, }), ).toEqual({ @@ -278,12 +279,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, }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0ca86ae8f32..f628e21e4a4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -189,11 +189,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 +208,7 @@ export function resolveSidebarNewThreadSeedContext(input: { branch: input.activeDraftThread.branch, worktreePath: input.activeDraftThread.worktreePath, envMode: input.activeDraftThread.envMode, + startFromOrigin: input.activeDraftThread.startFromOrigin, }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b943fb5a69d..1b46b0f1d04 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1875,6 +1875,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec branch: currentActiveDraftThread.branch, worktreePath: currentActiveDraftThread.worktreePath, envMode: currentActiveDraftThread.envMode, + startFromOrigin: currentActiveDraftThread.startFromOrigin, } : null, }); @@ -1889,6 +1890,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? { worktreePath: seedContext.worktreePath } : {}), envMode: seedContext.envMode, + ...(seedContext.startFromOrigin !== undefined + ? { startFromOrigin: seedContext.startFromOrigin } + : {}), }), ); if (result._tag === "Failure") { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f478eac7d96..71311c10d5c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -408,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin + ? ["New worktrees start from origin"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -426,6 +430,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.automaticGitFetchInterval, @@ -456,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -692,12 +698,16 @@ export function GeneralSettingsPanel() { title="New threads" description="Pick the default workspace mode for newly created draft threads." resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( + settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || + settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ( updateSettings({ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> @@ -729,6 +739,37 @@ export function GeneralSettingsPanel() { } /> + {settings.defaultThreadEnvMode === "worktree" ? ( + + updateSettings({ + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) + } + aria-label="Start new worktrees from origin by default" + /> + } + /> + ) : null} + { }); }); + it("stores the start-from-origin choice with the draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + envMode: "worktree", + startFromOrigin: true, + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(true); + + store.setDraftThreadContext(draftId, { startFromOrigin: false }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false); + }); + it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b92595227a6..fdb8bfe7b18 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -27,6 +27,7 @@ import { } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; +import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; @@ -214,6 +215,7 @@ const PersistedDraftThreadState = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, + startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -292,6 +294,7 @@ export interface DraftSessionState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + startFromOrigin: boolean; promotedTo?: ScopedThreadRef | null; } @@ -353,6 +356,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -367,6 +371,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -380,6 +385,7 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1313,6 +1319,7 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1333,6 +1340,12 @@ function createDraftThreadState( ? null : (existingThread?.branch ?? null) : (options.branch ?? null); + const nextStartFromOrigin = + options?.startFromOrigin === undefined + ? projectChanged + ? false + : (existingThread?.startFromOrigin ?? false) + : options.startFromOrigin; return { threadId, environmentId: projectRef.environmentId, @@ -1351,6 +1364,7 @@ function createDraftThreadState( : projectChanged ? "local" : (existingThread?.envMode ?? "local")), + startFromOrigin: nextStartFromOrigin, promotedTo: null, }; } @@ -1382,6 +1396,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && + left.startFromOrigin === right.startFromOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1476,6 +1491,7 @@ function normalizePersistedDraftThreads( const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; + const startFromOrigin = candidateDraftThread.startFromOrigin === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1523,6 +1539,7 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + startFromOrigin, promotedTo, }; } @@ -1568,6 +1585,7 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", + startFromOrigin: false, promotedTo: null, }; } else if ( @@ -2138,6 +2156,7 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, + startFromOrigin: persistedDraftThread.startFromOrigin, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2323,6 +2342,12 @@ const composerDraftStore = create()( ? null : existing.branch : (options.branch ?? null); + const nextStartFromOrigin = + options.startFromOrigin === undefined + ? projectChanged + ? false + : existing.startFromOrigin + : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2343,6 +2368,7 @@ const composerDraftStore = create()( : projectChanged ? "local" : (existing.envMode ?? "local")), + startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2355,6 +2381,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0b802dd8736..c99ae0af9b8 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,6 +20,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; @@ -27,6 +28,9 @@ import { useSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const newWorktreesStartFromOrigin = useSettings( + (settings) => settings.newWorktreesStartFromOrigin, + ); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -40,6 +44,7 @@ export function useNewThreadHandler() { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; }, ): Promise => { const { @@ -62,6 +67,7 @@ export function useNewThreadHandler() { const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; + const hasStartFromOriginOption = options?.startFromOrigin !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -80,11 +86,17 @@ export function useNewThreadHandler() { : null; if (reusableStoredDraftThread) { return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasStartFromOriginOption + ) { setDraftThreadContext(reusableStoredDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } setLogicalProjectDraftThreadId( @@ -114,11 +126,17 @@ export function useNewThreadHandler() { latestActiveDraftThread.logicalProjectKey === logicalProjectKey && latestActiveDraftThread.promotedTo == null ) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + if ( + hasBranchOption || + hasWorktreePathOption || + hasEnvModeOption || + hasStartFromOriginOption + ) { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { @@ -129,6 +147,7 @@ export function useNewThreadHandler() { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); return Promise.resolve(); } @@ -136,13 +155,20 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); + const initialEnvMode = options?.envMode ?? "local"; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: initialEnvMode, + startFromOrigin: + options?.startFromOrigin ?? + resolveNewDraftStartFromOrigin({ + envMode: initialEnvMode, + newWorktreesStartFromOrigin, + }), runtimeMode: DEFAULT_RUNTIME_MODE, }); applyStickyState(draftId); @@ -153,7 +179,7 @@ export function useNewThreadHandler() { }); })(); }, - [getCurrentRouteTarget, projectGroupingSettings, router, projects], + [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 45d22b1df91..62e5aa41d43 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, @@ -24,6 +25,21 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("only applies the start-from-origin default to new worktree drafts", () => { + expect( + resolveNewDraftStartFromOrigin({ + envMode: "worktree", + newWorktreesStartFromOrigin: true, + }), + ).toBe(true); + expect( + resolveNewDraftStartFromOrigin({ + envMode: "local", + newWorktreesStartFromOrigin: true, + }), + ).toBe(false); + }); + it("prefers the active draft thread project when resolving thread actions", () => { const projectRef = resolveThreadActionProjectRef( createContext({ @@ -33,6 +49,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, }), ); @@ -61,6 +78,7 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, }, handleNewThread, }), @@ -71,6 +89,32 @@ describe("chatThreadActions", () => { branch: "feature/refactor", worktreePath: "/tmp/worktree", envMode: "worktree", + startFromOrigin: true, + }); + }); + + it("preserves an explicitly disabled origin base in contextual thread options", async () => { + const handleNewThread = vi.fn(async () => {}); + + await startNewThreadFromContext( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + startFromOrigin: false, + }, + handleNewThread, + }), + ); + + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + startFromOrigin: false, }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index b434d1f519f..63d0289d104 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -11,6 +11,7 @@ interface ThreadContextLike { interface DraftThreadContextLike extends ThreadContextLike { envMode: DraftThreadEnvMode; + startFromOrigin: boolean; } interface NewThreadHandler { @@ -20,6 +21,7 @@ interface NewThreadHandler { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; }, ): Promise; } @@ -34,6 +36,13 @@ export interface ChatThreadActionContext { readonly handleNewThread: NewThreadHandler; } +export function resolveNewDraftStartFromOrigin(input: { + envMode: DraftThreadEnvMode; + newWorktreesStartFromOrigin: boolean; +}): boolean { + return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { @@ -57,6 +66,9 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre envMode: context.activeDraftThread?.envMode ?? (context.activeThread?.worktreePath ? "worktree" : "local"), + ...(context.activeDraftThread + ? { startFromOrigin: context.activeDraftThread.startFromOrigin } + : {}), }; } diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 3dc83933e38..29a732ca69b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -277,6 +277,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => projectCwd: "/tmp/workspace", baseBranch: "main", branch: "t3code/example", + startFromOrigin: true, }, runSetupScript: true, }, @@ -284,6 +285,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => }); assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); assert.strictEqual(parsed.bootstrap?.runSetupScript, true); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 46d51da371f..623fed0917b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -565,6 +565,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ projectCwd: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, branch: Schema.optional(TrimmedNonEmptyString), + startFromOrigin: Schema.optional(Schema.Boolean), }); const ThreadTurnStartBootstrap = Schema.Struct({ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..aba97cbe205 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -64,6 +64,18 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings worktree defaults", () => { + it("defaults start-from-origin off for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); + }); + + it("accepts start-from-origin updates", () => { + expect( + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, + ).toBe(true); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6955ab7050f..0463a441759 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -373,6 +373,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + newWorktreesStartFromOrigin: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -481,6 +484,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( From 0651af92b88728785a39bd16e8d411ad741b681f Mon Sep 17 00:00:00 2001 From: Ishan Date: Fri, 19 Jun 2026 09:11:14 +0530 Subject: [PATCH 008/142] fix(server): use bound host for MCP endpoint (#3114) --- .../server/src/mcp/McpSessionRegistry.test.ts | 34 +++++++++++++++---- apps/server/src/mcp/McpSessionRegistry.ts | 13 ++++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index d6540d567af..7616affaafd 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -8,16 +8,18 @@ import { ServerEnvironment } from "../environment/Services/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 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.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), }); -const makeRegistry = (now: () => number) => +const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => McpSessionRegistry.__testing .make({ now, @@ -25,7 +27,7 @@ const makeRegistry = (now: () => number) => maximumLifetimeMs: 1_000, }) .pipe( - Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(HttpServer.HttpServer, httpServer), Effect.provideService(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..c15480310d5 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -60,6 +60,17 @@ 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 = {}, ) { @@ -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) => From 804d44cfb153d264e4abc9adb9c958dd6720cfa5 Mon Sep 17 00:00:00 2001 From: Soorria Saruva Date: Fri, 19 Jun 2026 13:45:04 +0800 Subject: [PATCH 009/142] fix(ssh): fix support for remotes that use fnm (#2641) Co-authored-by: Cursor --- packages/ssh/src/tunnel.test.ts | 4 +++- packages/ssh/src/tunnel.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 2e5c1a69904..7e7a5a54276 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -105,7 +105,9 @@ describe("ssh tunnel scripts", () => { assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); - assert.include(script, 'eval "$(fnm env --use-on-cd --shell sh)"'); + assert.include(script, 'eval "$(fnm env --shell bash)"'); + assert.include(script, "fnm use --silent-if-unchanged"); + assert.include(script, "fnm use default"); assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); assert.include(script, 'NVM_DIR="$HOME/.nvm"'); assert.include(script, "nvm use --silent default"); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a7f0d68c2a3..e8c2b924759 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -398,7 +398,8 @@ ensure_remote_node_path() { prepend_path_if_dir "$FNM_DIR" prepend_path_if_dir "$HOME/.fnm" if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then - eval "$(fnm env --use-on-cd --shell sh)" >/dev/null 2>&1 || eval "$(fnm env --shell sh)" >/dev/null 2>&1 || true + eval "$(fnm env --shell bash)" >/dev/null 2>&1 || true + fnm use --silent-if-unchanged >/dev/null 2>&1 || fnm use default >/dev/null 2>&1 || true fi prepend_path_if_dir "$HOME/.nodenv/bin" From 20f37f367a146fb4118bd0f0182336f4ab8c6ac8 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Thu, 18 Jun 2026 23:46:12 -0600 Subject: [PATCH 010/142] Avoid repeated theme DOM sync during startup (#2779) Co-authored-by: Julius Marminge --- apps/web/src/hooks/useTheme.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 78d063a9609..eec2e9c9363 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -18,6 +18,7 @@ const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let lastAppliedTheme: ThemeSnapshot | null = null; function emitChange() { for (const listener of listeners) listener(); @@ -28,7 +29,11 @@ function hasThemeStorage() { } function getSystemDark() { - return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MEDIA_QUERY).matches + ); } function getStored(): Theme { @@ -89,11 +94,18 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, suppressTransitions = false) { if (typeof document === "undefined" || typeof window === "undefined") return; + const systemDark = theme === "system" ? getSystemDark() : false; + if (lastAppliedTheme?.theme === theme && lastAppliedTheme.systemDark === systemDark) { + syncDesktopTheme(theme); + return; + } + if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); + const isDark = theme === "dark" || (theme === "system" && systemDark); document.documentElement.classList.toggle("dark", isDark); + lastAppliedTheme = { theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { @@ -148,12 +160,12 @@ function subscribe(listener: () => void): () => void { listeners.push(listener); // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); + const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; const handleChange = () => { if (getStored() === "system") applyTheme("system", true); emitChange(); }; - mq.addEventListener("change", handleChange); + mq?.addEventListener("change", handleChange); // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { @@ -166,7 +178,7 @@ function subscribe(listener: () => void): () => void { return () => { listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); + mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } From 3e01c4bc572ea6a938c9a47143c914ff042ea41a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 10:28:28 -0700 Subject: [PATCH 011/142] Migrate desktop auth to Clerk bridge (#3092) Co-authored-by: codex --- .env.example | 7 + .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 15 + apps/desktop/package.json | 3 + apps/desktop/scripts/electron-launcher.mjs | 22 +- apps/desktop/src/app/DesktopApp.ts | 18 +- apps/desktop/src/app/DesktopClerk.test.ts | 95 +++ apps/desktop/src/app/DesktopClerk.ts | 92 ++ apps/desktop/src/app/DesktopCloudAuth.test.ts | 302 ------- apps/desktop/src/app/DesktopCloudAuth.ts | 330 -------- .../app/DesktopCloudAuthTokenStore.test.ts | 96 --- .../src/app/DesktopCloudAuthTokenStore.ts | 155 ---- .../DesktopLocalEnvironmentAuth.test.ts | 82 ++ .../backend/DesktopLocalEnvironmentAuth.ts | 77 ++ .../src/electron/ElectronProtocol.test.ts | 179 ++-- apps/desktop/src/electron/ElectronProtocol.ts | 306 ++----- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 14 +- apps/desktop/src/ipc/channels.ts | 8 +- .../desktop/src/ipc/methods/cloudAuth.test.ts | 97 --- apps/desktop/src/ipc/methods/cloudAuth.ts | 177 ---- apps/desktop/src/ipc/methods/window.ts | 11 + apps/desktop/src/main.ts | 26 +- apps/desktop/src/preload.ts | 22 +- apps/desktop/src/window/DesktopWindow.test.ts | 10 +- apps/desktop/src/window/DesktopWindow.ts | 37 +- apps/desktop/vite.config.ts | 6 + apps/mobile/package.json | 2 +- apps/mobile/src/connection/platform.ts | 5 + .../src/relay/AgentAwarenessRelay.test.ts | 6 +- apps/web/package.json | 4 +- apps/web/src/authBootstrap.test.ts | 37 +- apps/web/src/cloud/desktopAuth.test.ts | 59 -- apps/web/src/cloud/desktopAuth.ts | 144 ---- apps/web/src/cloud/desktopClerk.tsx | 322 ------- .../desktopClerkExternalAccounts.test.ts | 78 -- .../src/cloud/desktopClerkExternalAccounts.ts | 112 --- apps/web/src/cloud/linkEnvironment.test.ts | 30 + apps/web/src/cloud/linkEnvironment.ts | 31 +- .../src/components/clerk/DesktopClerkCard.tsx | 137 --- .../components/clerk/DesktopClerkSignIn.tsx | 150 ---- .../components/clerk/DesktopClerkWaitlist.tsx | 106 --- .../components/clerk/useDesktopClerkSignIn.ts | 199 ----- .../clerk/useT3ConnectAuthPrompt.tsx | 22 +- apps/web/src/connection/platform.ts | 61 +- .../environments/primary/desktopAuth.test.ts | 33 + .../src/environments/primary/desktopAuth.ts | 21 + .../environments/primary/httpLayer.test.ts | 65 ++ .../web/src/environments/primary/httpLayer.ts | 58 ++ apps/web/src/environments/primary/index.ts | 2 + .../src/environments/primary/requestInit.ts | 7 - apps/web/src/lib/runtime.ts | 14 +- apps/web/src/main.tsx | 7 +- apps/web/src/observability/clientTracing.ts | 5 +- apps/web/vite.config.ts | 1 + docs/cloud/t3-connect-clerk.md | 86 +- docs/operations/ci.md | 2 +- docs/operations/release.md | 36 +- docs/reference/scripts.md | 5 +- infra/relay/package.json | 2 +- .../src/connection/resolver.test.ts | 44 + .../client-runtime/src/connection/resolver.ts | 42 +- .../src/platform/capabilities.ts | 7 + packages/contracts/src/ipc.ts | 24 +- pnpm-lock.yaml | 794 +++++++++++------- pnpm-workspace.yaml | 17 + scripts/build-desktop-artifact.test.ts | 104 +++ scripts/build-desktop-artifact.ts | 235 +++++- 67 files changed, 1986 insertions(+), 3318 deletions(-) create mode 100644 apps/desktop/src/app/DesktopClerk.test.ts create mode 100644 apps/desktop/src/app/DesktopClerk.ts delete mode 100644 apps/desktop/src/app/DesktopCloudAuth.test.ts delete mode 100644 apps/desktop/src/app/DesktopCloudAuth.ts delete mode 100644 apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts delete mode 100644 apps/desktop/src/app/DesktopCloudAuthTokenStore.ts create mode 100644 apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts create mode 100644 apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts delete mode 100644 apps/desktop/src/ipc/methods/cloudAuth.test.ts delete mode 100644 apps/desktop/src/ipc/methods/cloudAuth.ts delete mode 100644 apps/web/src/cloud/desktopAuth.test.ts delete mode 100644 apps/web/src/cloud/desktopAuth.ts delete mode 100644 apps/web/src/cloud/desktopClerk.tsx delete mode 100644 apps/web/src/cloud/desktopClerkExternalAccounts.test.ts delete mode 100644 apps/web/src/cloud/desktopClerkExternalAccounts.ts delete mode 100644 apps/web/src/components/clerk/DesktopClerkCard.tsx delete mode 100644 apps/web/src/components/clerk/DesktopClerkSignIn.tsx delete mode 100644 apps/web/src/components/clerk/DesktopClerkWaitlist.tsx delete mode 100644 apps/web/src/components/clerk/useDesktopClerkSignIn.ts create mode 100644 apps/web/src/environments/primary/desktopAuth.test.ts create mode 100644 apps/web/src/environments/primary/desktopAuth.ts create mode 100644 apps/web/src/environments/primary/httpLayer.test.ts create mode 100644 apps/web/src/environments/primary/httpLayer.ts delete mode 100644 apps/web/src/environments/primary/requestInit.ts 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 eaf8fc367cc..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 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/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/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 52b6dd5cc6e..73d778fb48b 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -31,20 +31,12 @@ 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 LAUNCHER_VERSION = 12; const defaultIconPath = join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = 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", @@ -100,7 +92,6 @@ 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 envEntries = [ ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], ["T3CODE_PORT", process.env.T3CODE_PORT], @@ -109,23 +100,12 @@ 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( 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"), diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4da1ce63bdf..136a9dfd097 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,7 +11,7 @@ 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"; @@ -163,6 +163,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 +199,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 +218,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 +227,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")); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts new file mode 100644 index 00000000000..84eab6598a9 --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { 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 { + createDesktopClerkBridge, + resolveDesktopClerkFrontendApiHostname, +} from "./DesktopClerk.ts"; +import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +describe("DesktopClerk", () => { + it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { + const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; + + assert.equal(resolveDesktopClerkFrontendApiHostname(publishableKey), "clerk.t3.codes"); + assert.equal(resolveDesktopClerkFrontendApiHostname(""), undefined); + assert.equal(resolveDesktopClerkFrontendApiHostname("invalid"), undefined); + }); + + it.effect("acquires and releases the SDK bridge with the layer", () => { + const cleanup = vi.fn(); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup }); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment: true, + } as unknown as DesktopEnvironment.DesktopEnvironmentShape); + + return Effect.gen(function* () { + yield* Effect.scoped( + Layer.build( + DesktopClerk.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ), + ), + ); + + 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.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(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..5fa8e0ffbca --- /dev/null +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -0,0 +1,92 @@ +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 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 interface DesktopClerkShape { + readonly configure: Effect.Effect< + void, + never, + ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + >; +} + +export class DesktopClerk extends Context.Service()( + "@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, + }, + }); +} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* Effect.acquireRelease( + Effect.sync(() => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment)), + (bridge) => Effect.sync(() => bridge.cleanup()), + ); + + 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/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts new file mode 100644 index 00000000000..914b6ada071 --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -0,0 +1,82 @@ +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 { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +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..e70057ee13c --- /dev/null +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -0,0 +1,77 @@ +import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +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 Semaphore from "effect/Semaphore"; +import { HttpClient } from "effect/unstable/http"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; + +export interface DesktopLocalEnvironmentAuthShape { + readonly getBearerToken: Effect.Effect; +} + +export class DesktopLocalEnvironmentAuthError extends Data.TaggedError( + "DesktopLocalEnvironmentAuthError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class DesktopLocalEnvironmentAuth extends Context.Service< + DesktopLocalEnvironmentAuth, + DesktopLocalEnvironmentAuthShape +>()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} + +export const layer = Layer.effect( + DesktopLocalEnvironmentAuth, + 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 DesktopLocalEnvironmentAuthError({ + message: "Local backend is not configured.", + }); + } + 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 DesktopLocalEnvironmentAuthError({ + message: "Failed to create the local desktop bearer session.", + cause, + }), + ), + ); + yield* Ref.set(tokenRef, Option.some(session.access_token)); + return session.access_token; + }), + ) + .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); + + return DesktopLocalEnvironmentAuth.of({ getBearerToken }); + }), +); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2306c101c63..619b7e871ab 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,105 +1,132 @@ 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 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")); + + 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:", + ); + }), + ); - 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, - }, - }, - ], - ], - ]); - }), - ), - ), - ), + 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.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + assert.equal(response.status, 404); + assert.equal(netFetchMock.mock.calls.length, 0); }).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..3a3e9f180f7 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,18 +1,27 @@ -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 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 function getDesktopScheme(isDevelopment: boolean): string { + return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; +} + +export function getDesktopOrigin(isDevelopment: boolean): string { + return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`; +} + +export function getDesktopUrl(isDevelopment: boolean): string { + return `${getDesktopOrigin(isDevelopment)}/`; +} export class ElectronProtocolRegistrationError extends Data.TaggedError( "ElectronProtocolRegistrationError", @@ -21,252 +30,117 @@ export class ElectronProtocolRegistrationError extends Data.TaggedError( readonly cause: unknown; }> { override get message() { - return `Failed to register ${this.scheme}: file protocol.`; + return `Failed to register ${this.scheme}: protocol.`; } } -export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( - "ElectronProtocolStaticBundleMissingError", -)<{}> { - override get message() { - return "Desktop static bundle missing. Build apps/server (with bundled client) first."; - } +export interface DesktopProtocolRegistrationInput { + readonly scheme: string; + readonly targetOrigin: URL; + readonly backendOrigin: URL; + readonly clerkFrontendApiHostname: string | undefined; } 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 - >; + readonly registerDesktopProtocol: ( + input: DesktopProtocolRegistrationInput, + ) => Effect.Effect; } 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); - } - 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"), +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", ]; - 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); - } - } - 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"); - } - - const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; - const resolvedPath = environment.path.join(staticRoot, requestedPath); - - 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()); + 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; - }), - ), - ), + Electron.protocol.unhandle(input.scheme); + }).pipe(Effect.andThen(Ref.set(registered, false))), ); }, ); - 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/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 1a9d16380a4..180e44e52d9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,13 +1,6 @@ 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 { clearConnectionCatalog, @@ -40,6 +33,7 @@ import { import { confirm, getAppBranding, + getLocalEnvironmentBearerToken, getLocalEnvironmentBootstrap, openExternal, pickFolder, @@ -54,6 +48,7 @@ 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); @@ -80,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 e270ef404bb..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,6 +12,8 @@ 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_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; 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 9f6a964ac05..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(Object.fromEntries(headers.entries())), - 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/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1cb4d7265a1..708bb299ccc 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"; @@ -64,6 +65,16 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ }), }); +export const getLocalEnvironmentBearerToken = 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 = makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 33eac8ea646..326fc1af0ca 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -27,13 +27,13 @@ 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 DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; @@ -119,7 +119,6 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), - DesktopCloudAuthTokenStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); @@ -149,17 +148,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 35c34d39c9e..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,6 +42,8 @@ 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), @@ -95,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/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e22db07c0cd..679fa874482 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -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..e911d4ff766 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"; @@ -16,8 +15,8 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "../backend/DesktopServerExposure.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,16 +38,7 @@ 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; @@ -71,15 +60,6 @@ export class DesktopWindow extends Context.Service { - return Option.match(environment.devServerUrl, { - onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), - onSome: (url) => Effect.succeed(url.href), - }); -} - function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, platform: NodeJS.Platform, @@ -171,18 +151,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 +328,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; 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/mobile/package.json b/apps/mobile/package.json index f47fb9d2452..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", diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 580341941a6..8b959b40b15 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -3,6 +3,7 @@ import { CloudSession, EnvironmentOwnedDataCleanup, PlatformConnectionSource, + PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, } from "@t3tools/client-runtime/platform"; @@ -119,6 +120,10 @@ const capabilitiesLayer = Layer.succeedContext( }), }), ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), Context.add( RelayDeviceIdentity, RelayDeviceIdentity.of({ diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 6bdf62b104f..b81eb80884d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -625,7 +625,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; diff --git a/apps/web/package.json b/apps/web/package.json index 13973b18874..632e2d14395 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,8 @@ }, "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", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 6815cd70f8c..53c17c06402 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 () => { 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/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index b4a347fca9b..fe639d9c594 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,4 +1,5 @@ import { + type DesktopBridge, EnvironmentId, type RelayClientInstallProgressEvent, WS_METHODS, @@ -28,6 +29,7 @@ import { ManagedRelayDpopSigner, } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { collectCloudLinkTargets, @@ -146,6 +148,7 @@ beforeEach(() => { }); afterEach(() => { + __resetDesktopPrimaryAuthForTests(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -224,6 +227,33 @@ describe("web cloud link environment client", () => { }), ); + it.effect("uses desktop bearer auth for primary cloud link state", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, + }); + + yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); + + 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("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 360ef6d3626..a8f410acdfa 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -31,7 +31,7 @@ import { readPrimaryEnvironmentDescriptor, readPrimaryEnvironmentTarget, } from "../environments/primary"; -import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { resolveCloudPublicConfig } from "./publicConfig"; import { finishRelayClientInstall, @@ -327,11 +327,8 @@ export function readPrimaryCloudLinkState(input: { 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: { @@ -346,10 +343,9 @@ export function updatePrimaryCloudPreferences(input: { payload: input, }) .pipe( - withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), ); - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function unlinkPrimaryEnvironmentFromCloud(input: { @@ -360,10 +356,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { 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) { @@ -381,7 +374,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { ), ); } - }); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } export function linkPrimaryEnvironmentToCloud(input: { @@ -433,10 +426,7 @@ export function linkPrimaryEnvironmentToCloud(input: { 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, @@ -470,9 +460,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/components/clerk/DesktopClerkCard.tsx b/apps/web/src/components/clerk/DesktopClerkCard.tsx deleted file mode 100644 index e2e0c4f9aad..00000000000 --- a/apps/web/src/components/clerk/DesktopClerkCard.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "../../lib/utils"; - -// Mirrors Clerk's raised card/footer/branding composition for the desktop-native flow: -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardRoot.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardFooter.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx -// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/DevModeNotice.tsx -export function DesktopClerkCard({ - children, - footerAction, -}: { - children: ReactNode; - footerAction?: ReactNode; -}) { - return ( -
-
- {children} -
-
- {footerAction ? ( -
{footerAction}
- ) : null} - -
-
- ); -} - -export function DesktopClerkHeader({ title, subtitle }: { title: string; subtitle: string }) { - return ( -
-

{title}

-

{subtitle}

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

- {children} - -

- ); -} - -export function DesktopClerkAlert({ children }: { children?: ReactNode }) { - if (!children) return null; - - return ( -
- {children} -
- ); -} - -export function DesktopClerkInput({ - className, - ...props -}: React.ComponentPropsWithoutRef<"input">) { - return ( - - ); -} - -export function DesktopClerkPrimaryButton({ - children, - disabled, -}: { - children: ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - -function DesktopClerkBranding() { - const isDevelopmentMode = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_"); - - return ( -
- - Secured by{" "} - - clerk - - - {isDevelopmentMode ? ( - Development mode - ) : null} -
- ); -} diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.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/connection/platform.ts b/apps/web/src/connection/platform.ts index 5fe503ec383..c8426d5510b 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -3,6 +3,7 @@ import { CloudSession, EnvironmentOwnedDataCleanup, PlatformConnectionSource, + PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, } from "@t3tools/client-runtime/platform"; @@ -30,9 +31,9 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; -import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; import { isHostedStaticApp } from "../hostedPairing"; @@ -193,6 +194,16 @@ const capabilitiesLayer = Layer.effectContext( const identity = RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.none()), }); + const primaryAuth = PrimaryEnvironmentAuth.of({ + bearerToken: Effect.tryPromise({ + try: readDesktopPrimaryBearerToken, + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not load the desktop primary credential: ${String(cause)}`, + }), + }).pipe(Effect.map(Option.fromNullishOr)), + }); const ssh = SshEnvironmentGateway.of({ provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) { const bridge = window.desktopBridge; @@ -252,6 +263,7 @@ const capabilitiesLayer = Layer.effectContext( }); return Context.make(CloudSession, cloudSession).pipe( + Context.add(PrimaryEnvironmentAuth, primaryAuth), Context.add(RelayDeviceIdentity, identity), Context.add(ClientPresentation, presentation), Context.add(SshEnvironmentGateway, ssh), @@ -271,10 +283,7 @@ const loadPrimaryConnectionRegistration = Effect.fn( } const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), - Effect.mapError(mapRemoteEnvironmentError), - ); + }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError)); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, @@ -289,32 +298,24 @@ const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( Schedule.either(Schedule.spaced("16 seconds")), ); -const platformConnectionSourceLayer = Layer.effect( - PlatformConnectionSource, - Effect.gen(function* () { - if (isHostedStaticApp()) { - return PlatformConnectionSource.of({ - registrations: Stream.empty, - }); - } - const httpClient = yield* HttpClient.HttpClient; +const platformConnectionSourceLayer = Layer.sync(PlatformConnectionSource, () => { + if (isHostedStaticApp()) { return PlatformConnectionSource.of({ - registrations: Stream.fromEffect( - loadPrimaryConnectionRegistration().pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ), - ).pipe( - Stream.tapError((error) => - Effect.logWarning("Could not discover the primary environment.", { - error, - }), - ), - Stream.retry(primaryRegistrationRetrySchedule), - Stream.catchCause(() => Stream.empty), - ), + registrations: Stream.empty, }); - }), -); + } + return PlatformConnectionSource.of({ + registrations: Stream.fromEffect(loadPrimaryConnectionRegistration()).pipe( + Stream.tapError((error) => + Effect.logWarning("Could not discover the primary environment.", { + error, + }), + ), + Stream.retry(primaryRegistrationRetrySchedule), + Stream.catchCause(() => Stream.empty), + ), + }); +}); const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, diff --git a/apps/web/src/environments/primary/desktopAuth.test.ts b/apps/web/src/environments/primary/desktopAuth.test.ts new file mode 100644 index 00000000000..d87a6a0c7f8 --- /dev/null +++ b/apps/web/src/environments/primary/desktopAuth.test.ts @@ -0,0 +1,33 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; + +import { __resetDesktopPrimaryAuthForTests, readDesktopPrimaryBearerToken } from "./desktopAuth"; + +describe("desktop primary auth", () => { + beforeEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + }); + }); + + afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + Reflect.deleteProperty(globalThis, "window"); + }); + + it("reuses the main-process bearer token across renderer requests", async () => { + const getLocalEnvironmentBearerToken = vi.fn().mockResolvedValue("desktop-bearer-token"); + window.desktopBridge = { + getLocalEnvironmentBearerToken, + } as unknown as DesktopBridge; + + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-token"); + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-token"); + expect(getLocalEnvironmentBearerToken).toHaveBeenCalledTimes(1); + }); + + it("does not require desktop auth in a browser", async () => { + await expect(readDesktopPrimaryBearerToken()).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/environments/primary/desktopAuth.ts b/apps/web/src/environments/primary/desktopAuth.ts new file mode 100644 index 00000000000..325773d910d --- /dev/null +++ b/apps/web/src/environments/primary/desktopAuth.ts @@ -0,0 +1,21 @@ +let desktopBearerTokenPromise: Promise | null = null; + +export function readDesktopPrimaryBearerToken(): Promise { + if (typeof window === "undefined") { + return Promise.resolve(null); + } + const bridge = window.desktopBridge; + if (!bridge) { + return Promise.resolve(null); + } + + desktopBearerTokenPromise ??= bridge.getLocalEnvironmentBearerToken().catch((error) => { + desktopBearerTokenPromise = null; + throw error; + }); + return desktopBearerTokenPromise; +} + +export function __resetDesktopPrimaryAuthForTests(): void { + desktopBearerTokenPromise = null; +} diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts new file mode 100644 index 00000000000..5bc1ef01da1 --- /dev/null +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -0,0 +1,65 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; + +import { __resetDesktopPrimaryAuthForTests } from "./desktopAuth"; +import { makePrimaryEnvironmentHttpLayer } from "./httpLayer"; + +describe.sequential("primary environment HTTP layer", () => { + afterEach(() => { + __resetDesktopPrimaryAuthForTests(); + Reflect.deleteProperty(globalThis, "window"); + vi.unstubAllGlobals(); + }); + + it.effect("uses cookie credentials for browser primary environments", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "http://127.0.0.1:3773/settings", + origin: "http://127.0.0.1:3773", + }, + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://127.0.0.1:3773/api/auth/session"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); + + it.effect("uses bearer auth without cookies for desktop-managed primaries", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { origin: "t3code://app" }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + } as unknown as DesktopBridge, + }, + }); + + return Effect.gen(function* () { + yield* HttpClient.get("http://127.0.0.1:3773/api/connect/link-state"); + + const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); + expect(request.credentials).not.toBe("include"); + expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); + }).pipe(Effect.provide(makePrimaryEnvironmentHttpLayer())); + }); +}); diff --git a/apps/web/src/environments/primary/httpLayer.ts b/apps/web/src/environments/primary/httpLayer.ts new file mode 100644 index 00000000000..bedb4954d54 --- /dev/null +++ b/apps/web/src/environments/primary/httpLayer.ts @@ -0,0 +1,58 @@ +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { readDesktopPrimaryBearerToken } from "./desktopAuth"; +import { resolvePrimaryEnvironmentHttpUrl } from "./target"; + +function isSameOriginBrowserPrimary(): boolean { + if ( + typeof window === "undefined" || + window.desktopBridge !== undefined || + window.nativeApi !== undefined || + !window.location.origin.startsWith("http") + ) { + return false; + } + + return new URL(resolvePrimaryEnvironmentHttpUrl("/")).origin === window.location.origin; +} + +function withPrimaryBearerToken(client: HttpClient.HttpClient): HttpClient.HttpClient { + return client.pipe( + HttpClient.mapRequestEffect((request) => + Effect.promise(readDesktopPrimaryBearerToken).pipe( + Effect.map((bearerToken) => + bearerToken ? HttpClientRequest.bearerToken(request, bearerToken) : request, + ), + ), + ), + ); +} + +export function makePrimaryEnvironmentHttpLayer() { + return Layer.unwrap( + Effect.sync(() => { + const baseLayer = remoteHttpClientLayer(globalThis.fetch); + if (isSameOriginBrowserPrimary()) { + return Layer.merge( + baseLayer, + Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), + ); + } + + const bearerClientLayer = Layer.effect( + HttpClient.HttpClient, + Effect.map(HttpClient.HttpClient, withPrimaryBearerToken), + ).pipe(Layer.provide(baseLayer)); + + return Layer.merge( + bearerClientLayer, + Layer.succeed(FetchHttpClient.RequestInit, { credentials: "omit" }), + ); + }), + ); +} + +export const primaryEnvironmentHttpLayer = makePrimaryEnvironmentHttpLayer(); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 3728cb024b2..305ced9c905 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -33,6 +33,8 @@ export { export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; +export { PrimaryEnvironmentHttpClient } from "./httpClient"; + export { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, diff --git a/apps/web/src/environments/primary/requestInit.ts b/apps/web/src/environments/primary/requestInit.ts deleted file mode 100644 index cf70237380b..00000000000 --- a/apps/web/src/environments/primary/requestInit.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as Effect from "effect/Effect"; -import { FetchHttpClient } from "effect/unstable/http"; - -export const primaryEnvironmentRequestInit = { credentials: "include" } as const; - -export const withPrimaryEnvironmentRequestInit = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit)); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 728163b2491..cf4ffb0845d 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -1,17 +1,15 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { FetchHttpClient } from "effect/unstable/http"; import * as Socket from "effect/unstable/socket/Socket"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { PrimaryEnvironmentHttpClient, primaryEnvironmentHttpClientLive, } from "../environments/primary/httpClient"; -import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { browserCryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; @@ -32,15 +30,7 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig( export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( - primaryEnvironmentHttpClientLive.pipe( - Layer.provide( - Layer.mergeAll( - remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), - Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), - httpHeaderRedactionLayer, - ), - ), - ), + primaryEnvironmentHttpClientLive.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); export type PrimaryHttpEffectRunner = ( diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7d56d572f34..838a990d6c6 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; +import { passkeys } from "@clerk/electron/passkeys"; +import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@fontsource-variable/dm-sans/index.css"; @@ -10,7 +12,6 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; -import { DesktopClerkProvider } from "./cloud/desktopClerk"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; @@ -37,9 +38,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {clerkPublishableKey && hasCloudPublicConfig() ? ( isElectron ? ( - + {app} - + ) : ( {app} diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 61c6006a8f5..11045392bae 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -3,11 +3,12 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Scope from "effect/Scope"; import * as Tracer from "effect/Tracer"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -22,7 +23,7 @@ const CLIENT_TRACING_RESOURCE = { } as const; const delegateRuntimeLayer = Layer.mergeAll( - FetchHttpClient.layer, + primaryEnvironmentHttpLayer, OtlpSerialization.layerJson, Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 43c79eba305..8f984c850dc 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -126,6 +126,7 @@ export default defineConfig(() => { }, resolve: { tsconfigPaths: true, + dedupe: ["react", "react-dom"], }, server: { host, diff --git a/docs/cloud/t3-connect-clerk.md b/docs/cloud/t3-connect-clerk.md index d768c387413..3fd1943f7dc 100644 --- a/docs/cloud/t3-connect-clerk.md +++ b/docs/cloud/t3-connect-clerk.md @@ -120,20 +120,86 @@ selects the concrete relay deployment, but changing that URL does not require a ## Desktop OAuth Redirect Allowlist The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. -In **Clerk Dashboard > Native applications**, enable native application support and add these -entries under the mobile SSO redirect allowlist: +In **Clerk Dashboard > Native applications**, enable the Native API and add these entries under the +mobile SSO redirect allowlist: ```text -t3code-dev://auth/callback -t3code://auth/callback +t3code-dev://app/ +t3code://app/ ``` -The first entry is for local desktop development. The second is for packaged desktop builds. -The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial -sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps -Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, -and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth -redirect: an external browser cannot use it to reopen the packaged app. +Local desktop development uses `t3code-dev://app`, while packaged builds use `t3code://app`. Add the +matching origin to each Clerk instance's Backend API `allowed_origins` array as well. The development +Clerk instance should only need `t3code-dev://app`; the production Clerk instance should only need +`t3code://app`. `@clerk/electron` owns the native request adapter, encrypted Clerk token persistence, +external-browser OAuth transport, and callback delivery for initial sign-in and linked-account flows. + +There is currently no Dashboard UI for `allowed_origins`. Preserve any existing entries and update +the instance through the Backend API: + +```sh +curl -X PATCH https://api.clerk.com/v1/instance \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLERK_SECRET_KEY" \ + -d '{"allowed_origins":["t3code://app"]}' +``` + +Never put `CLERK_SECRET_KEY` in the desktop app, a client-facing environment file, or a build +artifact. + +## Desktop Passkeys + +The production macOS bundle ID is `com.t3tools.t3code`. To enable native passkeys: + +1. Create an explicit macOS App ID for `com.t3tools.t3code` in the Apple Developer portal and enable + **Associated Domains**. +2. Create a compatible macOS provisioning profile for that App ID and the certificate used to sign + the distributed app. +3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. This is + also the configuration point for Electron/macOS passkeys. +4. Confirm Clerk serves `https:///.well-known/apple-app-site-association` and that + `webcredentials.apps` contains `.com.t3tools.t3code`. +5. Set the local or CI signing configuration described below. + +For a local signed build, add these values to `.env.local` or export them before invoking the +desktop artifact command: + +```dotenv +T3CODE_APPLE_TEAM_ID=ABC1234567 +T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# Optional: comma-separated override when Clerk's RP ID differs from the Frontend API hostname. +T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com +``` + +When `T3CODE_CLERK_PASSKEY_RP_DOMAINS` is absent, the build derives the RP domain from +`T3CODE_CLERK_PUBLISHABLE_KEY`. Signed macOS builds fail early if the Team ID, provisioning profile, +or RP-domain configuration is missing. The generated main-app entitlements include every configured +`webcredentials:` entry; helper apps keep Electron's minimal default entitlements. + +The normal `dev:desktop` launcher is unsigned and cannot complete macOS passkey ceremonies. For +renderer HMR, build and install a signed app first, run the renderer dev server, then launch the +installed app executable with `VITE_DEV_SERVER_URL` and `T3CODE_PORT` set. Rebuild the signed app +after native dependency, main-process, preload, entitlement, provisioning, or signing changes; +renderer-only changes can reuse the installed app. + +For the default development ports, run `pnpm dev:web` in one terminal and launch the installed +binary from another: + +```sh +VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ +T3CODE_PORT=13773 \ + "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" +``` + +After changing Associated Domains, bump the build version before rebuilding; macOS may otherwise +reuse stale Shared Web Credentials metadata for the same app/version pair. + +Verify the installed bundle before testing: + +```sh +codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" +codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" +``` The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth flow uses a custom redirect URI, add that exact URI to the same allowlist. diff --git a/docs/operations/ci.md b/docs/operations/ci.md index 244446ba959..d030b446b6a 100644 --- a/docs/operations/ci.md +++ b/docs/operations/ci.md @@ -2,5 +2,5 @@ - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. -- The release workflow auto-enables signing only when secrets are present: Apple credentials for macOS and Azure Trusted Signing credentials for Windows. Without secrets, it still releases unsigned artifacts. +- The release workflow auto-enables signing only when platform credentials are present. macOS passkey builds additionally require `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts. - See [Release Checklist](./release.md) for the full release/signing setup checklist. diff --git a/docs/operations/release.md b/docs/operations/release.md index 8f149446b66..76f787dc023 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -219,26 +219,44 @@ Required secrets used by the workflow: - `APPLE_API_KEY` - `APPLE_API_KEY_ID` - `APPLE_API_ISSUER` +- `MACOS_PROVISIONING_PROFILE` (base64-encoded provisioning profile with Associated Domains) + +Required repository variables: + +- `APPLE_TEAM_ID` + +Optional repository variables: + +- `CLERK_PASSKEY_RP_DOMAINS`: comma-separated RP-domain override. By default, the build derives the + domain from the production Clerk publishable key. Checklist: 1. Apple Developer account access: - Team has rights to create Developer ID certificates. -2. Create `Developer ID Application` certificate. -3. Export certificate + private key as `.p12` from Keychain. -4. Base64-encode the `.p12` and store as `CSC_LINK`. -5. Store the `.p12` export password as `CSC_KEY_PASSWORD`. -6. In App Store Connect, create an API key (Team key). -7. Add API key values: +2. Create an explicit App ID for `com.t3tools.t3code` and enable Associated Domains. +3. Create a `Developer ID Application` certificate and a compatible provisioning profile for that + App ID with Associated Domains enabled. +4. Export the certificate + private key as `.p12` from Keychain. +5. Base64-encode the `.p12` and store as `CSC_LINK`. +6. Base64-encode the provisioning profile and store it as `MACOS_PROVISIONING_PROFILE`. +7. Store the `.p12` export password as `CSC_KEY_PASSWORD`, and set `APPLE_TEAM_ID` to the + 10-character Apple Developer Team ID. +8. In App Store Connect, create an API key (Team key). +9. Add API key values: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -8. Re-run a tag release and confirm macOS artifacts are signed/notarized. +10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../cloud/t3-connect-clerk.md#desktop-passkeys). +11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected + `com.apple.developer.associated-domains` entitlement. Notes: - `APPLE_API_KEY` is stored as raw key text in secrets. - The workflow writes it to a temporary `AuthKey_.p8` file at runtime. +- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it + to the desktop packager. ## 3) Azure Trusted Signing setup (Windows) @@ -281,7 +299,9 @@ Checklist: ## 5) Troubleshooting - macOS build unsigned when expected signed: - - Check all Apple secrets are populated and non-empty. + - Check all Apple secrets plus `APPLE_TEAM_ID` are populated and non-empty. + - Confirm the provisioning profile belongs to `APPLE_TEAM_ID.com.t3tools.t3code` and includes + Associated Domains. - Windows build unsigned when expected signed: - Check all Azure ATS and auth secrets are populated and non-empty. - Build fails with signing error: diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index b3fcd4b30e9..d4d2b96869e 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -20,11 +20,14 @@ - Default build is unsigned/not notarized for local sharing. - The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. -- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). +- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. +- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and + `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from + `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it. - Windows `--signed` uses Azure Trusted Signing and expects: `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. diff --git a/infra/relay/package.json b/infra/relay/package.json index 213c1fe5cc8..eebd9f4721a 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "3.6.1", + "@clerk/backend": "catalog:", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 31f75bf4bdc..5c1ed83ec6b 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -17,6 +17,7 @@ import { ConnectionResolver } from "./resolver.ts"; import { connectionResolverLayer } from "./resolver.ts"; import { CloudSession, + PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, } from "../platform/capabilities.ts"; @@ -101,6 +102,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly primaryBearerToken?: string; readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; }) => { const profiles = new Map( @@ -170,6 +172,12 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o Layer.succeed(ConnectionProfileStore, profileStore), Layer.succeed(ConnectionCredentialStore, credentialStore), Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), + Layer.succeed( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ + bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), + }), + ), Layer.succeed( RelayDeviceIdentity, RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), @@ -217,6 +225,42 @@ describe("ConnectionResolver", () => { }), ); + it.effect("authorizes a desktop primary environment with its platform bearer token", () => + Effect.gen(function* () { + const bearerInputs = yield* Ref.make>([]); + const brokerLayer = yield* makeDependencies({ + primaryBearerToken: "desktop-bearer", + authorizeBearer: (input) => + Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Primary", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, + target, + }); + expect(yield* Ref.get(bearerInputs)).toEqual(["desktop-bearer"]); + }), + ); + it.effect("uses the registered bearer profile without re-reading the profile store", () => Effect.gen(function* () { const bearerInputs = yield* Ref.make>([]); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 6eb0027e3a8..ae18535e4d0 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -10,6 +10,7 @@ import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import { ManagedRelayClient } from "../relay/managedRelay.ts"; import { CloudSession, + PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, } from "../platform/capabilities.ts"; @@ -58,17 +59,37 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string { return url.toString(); } -const primaryBroker = Effect.fn("clientRuntime.connection.broker.primary")( - (target: PrimaryConnectionTarget) => - Effect.succeed({ - environmentId: target.environmentId, - label: target.label, +const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { + const auth = yield* PrimaryEnvironmentAuth; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.primary")(function* ( + target: PrimaryConnectionTarget, + ) { + const bearerToken = yield* auth.bearerToken; + if (Option.isNone(bearerToken)) { + return { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: primarySocketUrl(target), + httpAuthorization: null, + target, + } satisfies PreparedConnection; + } + + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, httpBaseUrl: target.httpBaseUrl, - socketUrl: primarySocketUrl(target), - httpAuthorization: null, + wsBaseUrl: target.wsBaseUrl, + bearerToken: bearerToken.value, + }); + return { + ...authorized, target, - } satisfies PreparedConnection), -); + } satisfies PreparedConnection; + }); +}); const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { const credentials = yield* ConnectionCredentialStore; @@ -228,6 +249,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct export const connectionResolverLayer = Layer.effect( ConnectionResolver, Effect.gen(function* () { + const primary = yield* makePrimaryBroker(); const bearer = yield* makeBearerBroker(); const relay = yield* makeRelayBroker(); const ssh = yield* makeSshBroker(); @@ -242,7 +264,7 @@ export const connectionResolverLayer = Layer.effect( }); switch (target._tag) { case "PrimaryConnectionTarget": - return yield* primaryBroker(target); + return yield* primary(target); case "BearerConnectionTarget": return yield* bearer({ ...entry, target }); case "RelayConnectionTarget": diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index ddc93046b37..a20b7d404b2 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -43,6 +43,13 @@ export class ClientPresentation extends Context.Service< } >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} +export class PrimaryEnvironmentAuth extends Context.Service< + PrimaryEnvironmentAuth, + { + readonly bearerToken: Effect.Effect, ConnectionAttemptError>; + } +>()("@t3tools/client-runtime/platform/capabilities/PrimaryEnvironmentAuth") {} + export class SshEnvironmentGateway extends Context.Service< SshEnvironmentGateway, { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 03c06d2f81a..f9377d6bf8b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -415,23 +415,6 @@ export const PickFolderOptionsSchema = Schema.Struct({ initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), }); -export const DesktopCloudAuthFetchInputSchema = Schema.Struct({ - url: Schema.String, - method: Schema.optionalKey(Schema.String), - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.optionalKey(Schema.String), -}); -export type DesktopCloudAuthFetchInput = typeof DesktopCloudAuthFetchInputSchema.Type; - -export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ - ok: Schema.Boolean, - status: Schema.Number, - statusText: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}); -export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; - /** * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process * PreviewTabState shape but uses serialisable primitives only. @@ -897,6 +880,7 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog?: () => Promise; @@ -935,12 +919,6 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; - createCloudAuthRequest: () => Promise; - getCloudAuthToken: () => Promise; - setCloudAuthToken: (token: string) => Promise; - clearCloudAuthToken: () => Promise; - fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; - onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9312da667..1bd9272b6c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,13 @@ catalogs: version: 0.1.24 overrides: + '@clerk/backend': 3.8.2-snapshot.v20260619001138 + '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138 + '@clerk/electron': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/expo': 3.4.8-snapshot.v20260619001138 + '@clerk/react': 6.10.4-snapshot.v20260619001138 + '@clerk/shared': 4.19.2-snapshot.v20260619001138 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' @@ -105,6 +112,12 @@ importers: apps/desktop: dependencies: + '@clerk/electron': + specifier: 0.0.1-snapshot.v20260619001138 + version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/electron-passkeys': + specifier: 0.0.1-snapshot.v20260619001138 + version: 0.0.1-snapshot.v20260619001138 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -129,6 +142,9 @@ importers: electron: specifier: 41.5.0 version: 41.5.0 + electron-store: + specifier: ^8.2.0 + version: 8.2.0 electron-updater: specifier: ^6.6.2 version: 6.8.3 @@ -178,10 +194,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: ^3.4.1 - version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.4.8-snapshot.v20260619001138 + version: 3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -190,10 +206,10 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -205,7 +221,7 @@ importers: version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -226,7 +242,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) + version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -247,40 +263,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-asset: specifier: ~56.0.15 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -289,19 +305,19 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-network: specifier: ~56.0.5 version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(c021de11d02907bd585610408f5252e8) + version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -310,16 +326,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.15 - version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) + version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -331,43 +347,43 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: ^0.35.4 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 - version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -376,7 +392,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -466,12 +482,12 @@ importers: '@base-ui/react': specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/clerk-js': - specifier: ^6.16.0 - version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/electron': + specifier: 0.0.1-snapshot.v20260619001138 + version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: ^6.9.0 - version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.10.4-snapshot.v20260619001138 + version: 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -621,8 +637,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.6.1 - version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.8.2-snapshot.v20260619001138 + version: 3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1516,19 +1532,60 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.6.1': - resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} + '@clerk/backend@3.8.2-snapshot.v20260619001138': + resolution: {integrity: sha512-nT6M7rKTuvoDnSZwO3Th2NMjcWZy/0ZfXYyqd/o/lFpUFcQO0J4fM2e2wF9kNCl99EPvDQQUAR8APNNy/j40rg==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.16.0': - resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} + '@clerk/clerk-js@6.18.2-snapshot.v20260619001138': + resolution: {integrity: sha512-BpRSi2QXdfR5nnzC7/YCCqK40m1M4A/rN5unau7QKHj6V7xChl2fOvxYjekpH+DEyw6NAe/2jdqQv35iv3T5oA==} engines: {node: '>=20.9.0'} - '@clerk/expo@3.4.1': - resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} + '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-dbQ/0ZtfDQgYKCSzu3AMxTnGSrdZxulurZMT4Jpvin48Etc27PdcT7VvwOGIa7R+Ab8yMeaoLJbfwHTZv35F+Q==} + cpu: [arm64] + os: [darwin] + + '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-g9tbni7yKIJ/Xpollm25Gf7YLyFP24VyqRHcGDnEsHC1tIffG41spjLr10NgsBoRvFLMlaQJMSrfpjVOpBhAjw==} + cpu: [x64] + os: [darwin] + + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-mFDQ1vQ9dLIUrnjNGhIGxqyK0iiym919gYDPO3orYEcICdEiE8xZyEbCtKSVaeX6RWGJAcqFzCxbLVG6V+1k4Q==} + cpu: [arm64] + os: [win32] + + '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-IwkLw+d73bd7YJPRR8LAkqP42VIIpbJXCCbs5eVKbmam9M0vSUyeLWEtSiWUEypmLK13xv+7316K4GIA2tmDGQ==} + cpu: [x64] + os: [win32] + + '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-diU5Q9Nx+30mesrLFOmr0OCnmxE0ogUHXktZBTQx2nQvZb/n2UGOpGNLbY1p9edKMcSc5LfJEsQ8NogfLBBvPg==} + engines: {node: '>=20.9.0'} + + '@clerk/electron@0.0.1-snapshot.v20260619001138': + resolution: {integrity: sha512-wrBEdMMRqhMF4a7aQpZaBXKJfONIpaYLcgBl0m1b2r+Xg4yuZ47YUoDxhI2Ksvbx2KQPd4i9HP0enL6gEcoqfA==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': '>=0.0.6' + '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + electron: '>=28' + electron-store: ^8.2.0 + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + '@clerk/electron-passkeys': + optional: true + electron-store: + optional: true + react-dom: + optional: true + + '@clerk/expo@3.4.8-snapshot.v20260619001138': + resolution: {integrity: sha512-E6q4p5ded45aO3y/+f7APfy5pFj2Y/BEpe/1gzdr6UGxj9kJxkiZQV29hcpjYEFMEJK60f7XbOHZTQEZRB5OwQ==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@clerk/expo-passkeys': 1.1.8-snapshot.v20260619001138 expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1560,15 +1617,15 @@ packages: react-dom: optional: true - '@clerk/react@6.9.0': - resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} + '@clerk/react@6.10.4-snapshot.v20260619001138': + resolution: {integrity: sha512-Z7Otjly14SoxadMmk8d9ZdbaXU0me9B1zGdCtnNIcQ7X2GCbeyKm/lOM27IzWYXZKO6o+sXlqsq9A9tcxet5nA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.17.0': - resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} + '@clerk/shared@4.19.2-snapshot.v20260619001138': + resolution: {integrity: sha512-NAIz0L6+CaRrYn0DoXclUP1R0g6C2ljOzHogQ3rx9/SWUVpfaYX2lxPhURU89xvPoOGBZQb2xwk6xFh/7cjJfQ==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -4962,6 +5019,14 @@ packages: ajv: optional: true + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -5127,6 +5192,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5535,6 +5604,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} @@ -5643,6 +5716,10 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -5782,6 +5859,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -5940,6 +6021,9 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + electron-store@8.2.0: + resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -6488,6 +6572,10 @@ packages: find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -6917,6 +7005,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -7040,6 +7132,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -7323,6 +7418,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7677,6 +7776,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -7985,6 +8088,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -7993,6 +8100,10 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-queue@9.3.0: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} @@ -8001,6 +8112,10 @@ packages: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -8031,6 +8146,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-expression-matcher@1.5.0: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} @@ -8141,6 +8260,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -9211,6 +9334,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -10712,10 +10839,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.0': dependencies: @@ -10744,18 +10871,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10770,9 +10897,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10787,40 +10914,72 @@ snapshots: - react - react-dom - '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': + optional: true + + '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': + optional: true + + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': + optional: true + + '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': + optional: true + + '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': + optionalDependencies: + '@clerk/electron-passkeys-darwin-arm64': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys-darwin-x64': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.1-snapshot.v20260619001138 + + '@clerk/electron@0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + electron: 41.5.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + electron-store: 8.2.0 + react-dom: 19.2.6(react@19.2.6) + + '@clerk/expo@3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10830,7 +10989,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11370,7 +11529,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -11380,7 +11539,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -11405,7 +11564,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11431,8 +11590,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -11494,18 +11653,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -11566,13 +11725,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -11602,7 +11761,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) transitivePeerDependencies: - bufferutil - supports-color @@ -11620,14 +11779,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -11702,14 +11861,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -11724,18 +11883,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': + '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -11996,13 +12155,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -12825,15 +12984,15 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -12893,7 +13052,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -12903,7 +13062,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.1 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -12951,7 +13110,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -12959,16 +13118,18 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13381,15 +13542,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': dependencies: - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.8) - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14060,6 +14221,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -14355,6 +14520,8 @@ snapshots: at-least-node@1.0.0: {} + atomically@1.7.0: {} + auto-bind@5.0.1: {} aws-ssl-profiles@1.1.2: {} @@ -14459,8 +14626,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) transitivePeerDependencies: - '@babel/core' - supports-color @@ -14821,6 +14988,19 @@ snapshots: concat-map@0.0.1: {} + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.1 + connect@3.7.0: dependencies: debug: 2.6.9 @@ -14923,6 +15103,10 @@ snapshots: culori@4.0.2: {} + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -15052,6 +15236,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + dotenv-expand@11.0.7: dependencies: dotenv: 16.6.1 @@ -15142,6 +15330,11 @@ snapshots: transitivePeerDependencies: - supports-color + electron-store@8.2.0: + dependencies: + conf: 10.2.0 + type-fest: 2.19.0 + electron-to-chromium@1.5.364: {} electron-updater@6.8.3: @@ -15325,29 +15518,29 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -15355,119 +15548,119 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) resolve-from: 5.0.0 semver: 7.8.1 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15480,66 +15673,66 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-network@56.0.5(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(c021de11d02907bd585610408f5252e8): + expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -15547,18 +15740,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -15570,7 +15763,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-server@56.0.4: {} @@ -15578,7 +15771,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15586,20 +15779,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -15607,7 +15800,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15617,25 +15810,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): + expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -15644,37 +15837,37 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -15862,6 +16055,10 @@ snapshots: find-my-way-ts@0.1.6: {} + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + flattie@1.1.1: {} flow-enums-runtime@0.0.6: {} @@ -16388,6 +16585,8 @@ snapshots: is-number@7.0.0: {} + is-obj@2.0.0: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -16488,6 +16687,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: {} json-stringify-safe@5.0.1: @@ -16715,6 +16916,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -17363,6 +17569,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-function@5.0.1: {} mimic-response@1.0.1: {} @@ -17703,6 +17911,10 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -17711,6 +17923,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-queue@9.3.0: dependencies: eventemitter3: 5.0.4 @@ -17718,6 +17934,8 @@ snapshots: p-timeout@7.0.1: {} + p-try@2.2.0: {} + package-manager-detector@1.6.0: {} pako@1.0.11: {} @@ -17755,6 +17973,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-expression-matcher@1.5.0: {} path-is-absolute@1.0.1: {} @@ -17847,6 +18067,10 @@ snapshots: pkce-challenge@5.0.1: {} + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + playwright-core@1.60.0: {} plist@3.1.0: @@ -18061,102 +18285,102 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.1 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18168,23 +18392,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.1 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -19100,6 +19324,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@2.19.0: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -19211,14 +19437,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 233680b725f..03960dfbbdd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,13 @@ packages: - scripts catalog: + "@clerk/backend": 3.8.2-snapshot.v20260619001138 + "@clerk/clerk-js": 6.18.2-snapshot.v20260619001138 + "@clerk/electron": 0.0.1-snapshot.v20260619001138 + "@clerk/electron-passkeys": 0.0.1-snapshot.v20260619001138 + "@clerk/expo": 3.4.8-snapshot.v20260619001138 + "@clerk/react": 6.10.4-snapshot.v20260619001138 + "@clerk/shared": 4.19.2-snapshot.v20260619001138 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 @@ -40,6 +47,16 @@ onlyBuiltDependencies: - sharp overrides: + # Keep every Clerk consumer on the same snapshot train. Clerk publishes wallet + # auth integrations as required dependencies, but T3 Code does not support + # wallet auth, so keep that unused dependency tree out of installs. + "@clerk/backend": "catalog:" + "@clerk/clerk-js": "catalog:" + "@clerk/electron": "catalog:" + "@clerk/electron-passkeys": "catalog:" + "@clerk/expo": "catalog:" + "@clerk/react": "catalog:" + "@clerk/shared": "catalog:" "@clerk/clerk-js>@base-org/account": "-" "@clerk/clerk-js>@coinbase/wallet-sdk": "-" "@clerk/clerk-js>@solana/wallet-adapter-base": "-" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8135f7e259d..b0d84bb12b5 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -8,7 +8,11 @@ import * as Option from "effect/Option"; import { createStageWorkspaceConfig, createStagePnpmConfig, + createBuildConfig, DESKTOP_ASAR_UNPACK, + renderMacPasskeyEntitlements, + resolveClerkPasskeyNativeArtifacts, + resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, resolveFffNativeDependencies, resolveBuildOptions, @@ -175,6 +179,86 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); }); + it("derives macOS passkey signing configuration from the Clerk publishable key", () => { + const configuration = resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "abc1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: `pk_test_${btoa("example.clerk.accounts.dev$")}`, + }); + + assert.deepStrictEqual(configuration, { + appId: "com.t3tools.t3code", + teamId: "ABC1234567", + rpDomains: ["example.clerk.accounts.dev"], + provisioningProfilePath: "/tmp/t3code.provisionprofile", + }); + }); + + it("normalizes explicit macOS passkey RP domains and renders required entitlements", () => { + const configuration = resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: + " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ", + }); + const entitlements = renderMacPasskeyEntitlements(configuration); + + assert.deepStrictEqual(configuration.rpDomains, [ + "clerk.example.com", + "example.clerk.accounts.dev", + ]); + assert.include(entitlements, "ABC1234567.com.t3tools.t3code"); + assert.include(entitlements, "webcredentials:clerk.example.com"); + assert.include(entitlements, "webcredentials:example.clerk.accounts.dev"); + assert.include(entitlements, "com.apple.security.cs.allow-jit"); + }); + + it("rejects incomplete macOS passkey signing configuration", () => { + assert.throws( + () => + resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", + }), + /T3CODE_MACOS_PROVISIONING_PROFILE/u, + ); + assert.throws( + () => + resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "https://example.clerk.accounts.dev/path", + }), + /Invalid passkey RP domain/u, + ); + assert.throws( + () => + resolveMacPasskeySigningConfiguration({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev:8443", + }), + /Invalid passkey RP domain/u, + ); + }); + + it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => + Effect.gen(function* () { + const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, { + entitlementsPath: "/tmp/entitlements.mac.plist", + provisioningProfilePath: "/tmp/t3code.provisionprofile", + }); + + const mac = config.mac as Record; + assert.equal(config.appId, "com.t3tools.t3code"); + assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); + assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); + assert.deepStrictEqual(mac.protocols, [ + { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, + ]); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it("promotes target fff binaries to direct staged dependencies", () => { assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", @@ -192,6 +276,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); + it("resolves target Clerk passkey native artifacts", () => { + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("mac", "universal"), [ + { + packageName: "@clerk/electron-passkeys-darwin-arm64", + binaryFileName: "electron-passkeys.darwin-arm64.node", + }, + { + packageName: "@clerk/electron-passkeys-darwin-x64", + binaryFileName: "electron-passkeys.darwin-x64.node", + }, + ]); + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("win", "x64"), [ + { + packageName: "@clerk/electron-passkeys-win32-x64-msvc", + binaryFileName: "electron-passkeys.win32-x64-msvc.node", + }, + ]); + assert.deepStrictEqual(resolveClerkPasskeyNativeArtifacts("linux", "x64"), []); + }); + it("falls back to the default mock update port when the configured port is blank", () => { assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6b519b1d4e3..8aa95c3e68d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,7 +1,10 @@ #!/usr/bin/env node +import { createRequire } from "node:module"; + import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; @@ -9,6 +12,7 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -27,6 +31,8 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; +const DESKTOP_APP_ID = "com.t3tools.t3code"; +const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); @@ -293,6 +299,121 @@ interface StagePackageJson { export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; +export interface MacPasskeySigningConfiguration { + readonly appId: string; + readonly teamId: string; + readonly rpDomains: readonly string[]; + readonly provisioningProfilePath: string; +} + +function normalizePasskeyRpDomain(value: string): string { + const normalized = value.trim().toLowerCase(); + let parsed: URL; + try { + parsed = new URL(`https://${normalized}`); + } catch { + throw new Error(`Invalid passkey RP domain: ${value}`); + } + + if ( + normalized.length === 0 || + parsed.host !== normalized || + parsed.username.length > 0 || + parsed.password.length > 0 || + parsed.port.length > 0 || + parsed.pathname !== "/" || + parsed.search.length > 0 || + parsed.hash.length > 0 + ) { + throw new Error(`Invalid passkey RP domain: ${value}`); + } + + return parsed.hostname; +} + +export function resolveMacPasskeySigningConfiguration( + env: Readonly>, +): MacPasskeySigningConfiguration { + const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; + if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { + throw new Error("T3CODE_APPLE_TEAM_ID must be a 10-character Apple Developer Team ID."); + } + + const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; + if (provisioningProfilePath.length === 0) { + throw new Error( + "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", + ); + } + + const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim(); + let rpDomains: readonly string[]; + if (configuredRpDomains) { + rpDomains = configuredRpDomains.split(",").map(normalizePasskeyRpDomain); + } else { + const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim(); + if (!publishableKey) { + throw new Error( + "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.", + ); + } + rpDomains = [ + normalizePasskeyRpDomain(clerkFrontendApiHostnameFromPublishableKey(publishableKey)), + ]; + } + + const uniqueRpDomains = [...new Set(rpDomains)]; + if (uniqueRpDomains.length === 0) { + throw new Error("At least one Clerk passkey RP domain is required."); + } + + return { + appId: DESKTOP_APP_ID, + teamId, + rpDomains: uniqueRpDomains, + provisioningProfilePath, + }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderMacPasskeyEntitlements( + configuration: MacPasskeySigningConfiguration, +): string { + const associatedDomains = configuration.rpDomains + .map((domain) => ` webcredentials:${escapeXml(domain)}`) + .join("\n"); + + return ` + + + + com.apple.application-identifier + ${escapeXml(`${configuration.teamId}.${configuration.appId}`)} + com.apple.developer.team-identifier + ${escapeXml(configuration.teamId)} + com.apple.developer.associated-domains + +${associatedDomains} + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + +`; +} + export function resolveFffNativeDependencies( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -319,6 +440,63 @@ export function resolveFffNativeDependencies( ); } +export interface ClerkPasskeyNativeArtifact { + readonly packageName: string; + readonly binaryFileName: string; +} + +export function resolveClerkPasskeyNativeArtifacts( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): readonly ClerkPasskeyNativeArtifact[] { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return architectures.map((architecture) => ({ + packageName: `@clerk/electron-passkeys-darwin-${architecture}`, + binaryFileName: `electron-passkeys.darwin-${architecture}.node`, + })); + } + + if (platform === "win") { + return architectures.map((architecture) => ({ + packageName: `@clerk/electron-passkeys-win32-${architecture}-msvc`, + binaryFileName: `electron-passkeys.win32-${architecture}-msvc.node`, + })); + } + + return []; +} + +// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only +// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage +// the binary there and let electron-builder's native-addon handling unpack it from the ASAR. +const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinaries")(function* ( + stageAppDir: string, + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const packageEntryPath = yield* fs.realPath( + path.join(stageAppDir, "node_modules", "@clerk", "electron-passkeys", "index.js"), + ); + const packageDir = path.dirname(packageEntryPath); + const packageRequire = createRequire(packageEntryPath); + + for (const artifact of resolveClerkPasskeyNativeArtifacts(platform, arch)) { + const sourcePath = yield* Effect.try({ + try: () => packageRequire.resolve(artifact.packageName), + catch: (cause) => + new BuildScriptError({ + message: `Clerk passkey native package is missing: ${artifact.packageName}`, + cause, + }), + }); + yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName)); + } +}); + export function createStageWorkspaceConfig( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -739,16 +917,22 @@ export function resolveDesktopProductName(version: string): string { : (desktopPackageJson.productName ?? "T3 Code"); } -const createBuildConfig = Effect.fn("createBuildConfig")(function* ( +export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, version: string, signed: boolean, mockUpdates: boolean, mockUpdateServerPort: number | undefined, + macPasskeySigning: + | { + readonly entitlementsPath: string; + readonly provisioningProfilePath: string; + } + | undefined, ) { const buildConfig: Record = { - appId: "com.t3tools.t3code", + appId: DESKTOP_APP_ID, productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", asarUnpack: [...DESKTOP_ASAR_UNPACK], @@ -777,9 +961,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( protocols: [ { name: "T3 Code", - schemes: ["t3code"], + schemes: ["t3code", "t3code-dev"], }, ], + ...(macPasskeySigning + ? { + entitlements: macPasskeySigning.entitlementsPath, + provisioningProfile: macPasskeySigning.provisioningProfilePath, + } + : {}), }; } @@ -956,6 +1146,38 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const configuredMacPasskeySigning = + options.platform === "mac" && options.signed + ? yield* Effect.try({ + try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), + catch: (cause) => + new BuildScriptError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }) + : undefined; + const macPasskeySigning = configuredMacPasskeySigning + ? { + ...configuredMacPasskeySigning, + provisioningProfilePath: path.resolve( + repoRoot, + configuredMacPasskeySigning.provisioningProfilePath, + ), + } + : undefined; + const macEntitlementsPath = macPasskeySigning + ? path.join(stageAppDir, "entitlements.mac.plist") + : undefined; + if (macPasskeySigning && macEntitlementsPath) { + if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { + return yield* new BuildScriptError({ + message: `macOS provisioning profile not found: ${macPasskeySigning.provisioningProfilePath}`, + }); + } + yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); + } + const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, @@ -983,6 +1205,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.signed, options.mockUpdates, options.mockUpdateServerPort, + macPasskeySigning && macEntitlementsPath + ? { + entitlementsPath: macEntitlementsPath, + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + } + : undefined, ), dependencies: stageDependencies, devDependencies: { @@ -1014,6 +1242,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }), { label: "vp install --prod", verbose: options.verbose }, ); + yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") // as enabled, so copy the host env and scrub empty values instead of relying From 5d4e2fae012b74cfb323cc2cb45687caf93997cc Mon Sep 17 00:00:00 2001 From: Ulises Britos <45952970+repparw@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:07:23 -0300 Subject: [PATCH 012/142] feat: allow disabling provider update checks (#3130) Co-authored-by: codex --- .../src/provider/Drivers/ClaudeDriver.ts | 28 +++++++---- .../src/provider/Drivers/CodexDriver.ts | 28 +++++++---- .../src/provider/Drivers/CursorDriver.ts | 25 ++++++---- .../server/src/provider/Drivers/GrokDriver.ts | 25 ++++++---- .../src/provider/Drivers/OpenCodeDriver.ts | 46 ++++++++++++------- .../src/provider/Layers/CursorProvider.ts | 5 +- .../src/provider/Layers/GrokProvider.ts | 5 +- .../ProviderInstanceRegistryLive.test.ts | 3 ++ .../src/provider/makeManagedServerProvider.ts | 2 +- .../src/provider/providerMaintenance.test.ts | 42 ++++++++++++++++- .../src/provider/providerMaintenance.ts | 15 +++++- .../src/provider/providerUpdateSettings.ts | 43 +++++++++++++++++ .../components/settings/SettingsPanels.tsx | 27 +++++++++++ packages/contracts/src/settings.ts | 2 + 14 files changed, 239 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/provider/providerUpdateSettings.ts 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/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 35d5413714c..12eb6054145 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1106,6 +1106,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,7 +1118,9 @@ 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)), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 35611398b4b..b1c84fb3a03 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -311,12 +311,15 @@ 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) => 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/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/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index c4ad2fa7509..1018d123bb7 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -4,13 +4,14 @@ import { chmodSync, mkdirSync, symlinkSync, writeFileSync } 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 { 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, @@ -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({ 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/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts new file mode 100644 index 00000000000..564af26c78e --- /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 { ServerSettingsShape } 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: ServerSettingsShape, +): { + 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/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 71311c10d5c..5ecf009a08f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -668,6 +668,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) + } + aria-label="Check provider versions" + /> + } + /> + Date: Fri, 19 Jun 2026 11:15:35 -0700 Subject: [PATCH 013/142] Use idiomatic Effect options for server secret reads (#3110) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge Co-authored-by: codex --- .../server/src/auth/ServerSecretStore.test.ts | 45 +++++----- apps/server/src/auth/ServerSecretStore.ts | 88 ++++++++++--------- apps/server/src/cli/connect.ts | 6 +- apps/server/src/cloud/CliState.test.ts | 11 +-- apps/server/src/cloud/CliState.ts | 3 +- apps/server/src/cloud/CliTokenManager.ts | 4 +- .../src/cloud/ManagedEndpointRuntime.ts | 4 +- apps/server/src/cloud/environmentKeys.test.ts | 21 +++-- apps/server/src/cloud/environmentKeys.ts | 28 +++--- apps/server/src/cloud/http.ts | 44 +++++----- .../src/relay/AgentAwarenessRelay.test.ts | 7 +- apps/server/src/relay/AgentAwarenessRelay.ts | 8 +- apps/server/src/serverSettings.ts | 3 +- 13 files changed, 148 insertions(+), 124 deletions(-) diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 93339f4d4db..f18e59e6293 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,10 +1,11 @@ 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"; @@ -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.SecretStoreError); + 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.SecretStoreError); + 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.SecretStoreError); + 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..0dc4a6bb544 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -1,19 +1,23 @@ 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"; -export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class SecretStoreError extends Schema.TaggedErrorClass()( + "SecretStoreError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} const isPlatformError = (value: unknown): value is PlatformError.PlatformError => Predicate.isTagged(value, "PlatformError"); @@ -22,7 +26,7 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect; + 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: ( @@ -57,10 +61,10 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const get: ServerSecretStoreShape["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}.`, @@ -133,41 +137,43 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const getOrCreateRandom: ServerSecretStoreShape["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 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( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name} after concurrent creation.`, + }), + ), + }), + ), + ) + : Effect.fail(error), + ), + ), ), ), - ), - ); - }), + }), + ), Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 54f9fd40da9..9c8fb17a18b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -385,9 +385,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/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..88a61f5df74 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -100,8 +100,8 @@ const make = Effect.gen(function* () { 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* ( diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 7c8735b12e0..f2eedaf0c6d 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -22,10 +22,10 @@ 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 { diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 3a033d50303..5c20cd64ed3 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.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 PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -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: () => @@ -78,7 +81,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it remove: unusedSecretStoreOperation, } satisfies ServerSecretStore.ServerSecretStoreShape; - 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..f051d8265cb 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"; @@ -33,14 +34,15 @@ const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( secrets: ServerSecretStore.ServerSecretStoreShape, ) { 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( + const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( Effect.mapError((cause) => keyPairPersistenceError("Failed to decode environment signing key pair.", cause), ), ); + return Option.some(decoded); }); const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( @@ -57,14 +59,16 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio Effect.catchTag("SecretStoreError", (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? readEnvironmentKeyPair(secrets).pipe( - Effect.flatMap((existing) => - existing !== null - ? Effect.succeed(existing) - : Effect.fail( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( keyPairPersistenceError( "Failed to read environment signing key pair after concurrent creation.", ), ), + }), ), ) : Effect.fail(error), @@ -76,16 +80,16 @@ export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* secrets: ServerSecretStore.ServerSecretStoreShape, ) { 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.ts b/apps/server/src/cloud/http.ts index 89928ae13a2..773891124c5 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -220,10 +220,10 @@ function validateLinkedCloudUser(input: { }), ), 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( @@ -248,8 +248,8 @@ function readInstalledCloudUserId( }), ), Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( new EnvironmentAuth.ServerAuthInternalError({ message: "Cloud linked user is not installed for this environment.", @@ -622,12 +622,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; }); @@ -690,8 +690,8 @@ 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)) + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( new EnvironmentAuth.ServerAuthInternalError({ message: "Cloud mint public key is not installed for this environment.", @@ -701,12 +701,12 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( ); const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) : dependencies.secrets.get(RELAY_URL_SECRET).pipe( Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) : Effect.fail( new EnvironmentAuth.ServerAuthInternalError({ message: "Cloud relay issuer is not installed for this environment.", @@ -807,8 +807,8 @@ 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)) + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( new EnvironmentAuth.ServerAuthInternalError({ message: "Cloud mint public key is not installed for this environment.", @@ -818,12 +818,12 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") ); const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( Effect.flatMap((bytes) => - bytes - ? Effect.succeed(bytesToString(bytes)) + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) : dependencies.secrets.get(RELAY_URL_SECRET).pipe( Effect.flatMap((fallbackBytes) => - fallbackBytes - ? Effect.succeed(bytesToString(fallbackBytes)) + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) : Effect.fail( new EnvironmentAuth.ServerAuthInternalError({ message: "Cloud relay issuer is not installed for this environment.", diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b81eb80884d..4d31bb26137 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -64,9 +64,10 @@ 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.ServerSecretStoreShape["get"], set: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 280f61bcb20..91babdce4eb 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -276,7 +276,13 @@ const make = Effect.gen(function* () { 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([ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0e126604b4a..6e1ceb16a8d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -32,6 +32,7 @@ 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"; @@ -350,7 +351,7 @@ const makeServerSettings = Effect.gen(function* () { ); environment.push({ ...variable, - value: secret ? textDecoder.decode(secret) : "", + value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }); } providerInstances[instanceId] = { From 7dc182337cfddf3de1a3b2c2e1ccae810f60901a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:16:37 +0200 Subject: [PATCH 014/142] [codex] fix: show nightly badge from primary web server version (#3103) Co-authored-by: codex --- apps/web/src/components/Sidebar.logic.test.ts | 39 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 11 ++++++ apps/web/src/components/Sidebar.tsx | 11 +++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b1c29888f9b..574e33d4dab 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,6 +14,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -36,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; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f628e21e4a4..5c70d447d2b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -12,6 +12,7 @@ import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; @@ -64,6 +65,16 @@ export interface ThreadJumpHintVisibilityController { dispose: () => void; } +export function resolveSidebarStageBadgeLabel(input: { + primaryServerVersion: string | null | undefined; + fallbackStageLabel: string; +}): string { + return input.primaryServerVersion && + NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) + ? "Nightly" + : input.fallbackStageLabel; +} + export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b46b0f1d04..51773863207 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -188,6 +188,7 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -197,7 +198,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -2680,6 +2681,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 = (
@@ -2696,7 +2703,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ Code - {APP_STAGE_LABEL} + {stageBadgeLabel} } From 494350cc0b8b85702d1207cd96912c8325588a63 Mon Sep 17 00:00:00 2001 From: Icarus Wings <10465470+TheIcarusWings@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:20:59 +0100 Subject: [PATCH 015/142] feat(composer): clickable PR pill next to branch selector (#3065) Co-authored-by: codex --- .../BranchToolbarBranchSelector.tsx | 59 +++++++++++++++---- apps/web/src/components/Sidebar.tsx | 25 +------- apps/web/src/lib/openPullRequestLink.ts | 37 ++++++++++++ 3 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/lib/openPullRequestLink.ts diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index f8a2e1a6fcd..7798f38e43e 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -20,6 +20,7 @@ import { } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -37,9 +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 { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Combobox, ComboboxEmpty, @@ -51,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; @@ -525,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 ( @@ -621,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} + + +
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 51773863207..38d7c362983 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -69,6 +69,7 @@ import { } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -1156,29 +1157,7 @@ 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 openPrLink = useOpenPrLink(); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); const sidebarThreadByKey = useMemo( () => diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts new file mode 100644 index 00000000000..899e5c38c58 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -0,0 +1,37 @@ +import { type MouseEvent, useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { readLocalApi } from "../localApi"; + +/** + * Returns a click handler that opens a pull request URL in the system browser. + * + * Stops event propagation/default so activating the link does not also trigger + * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a + * toast when the local API is unavailable or the open fails. + */ +export function useOpenPrLink() { + return useCallback((event: MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void 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.", + }), + ); + }); + }, []); +} From a4446e263d77bd5c32ce6bb4921d6464000765de Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:21:39 -0700 Subject: [PATCH 016/142] Improve idiomatic Effect usage in config and Tailscale paths (#3073) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 43 ++++++++++++++++++ apps/server/src/vcs/VcsProjectConfig.ts | 48 ++++++++------------ packages/tailscale/src/tailscale.test.ts | 42 +++++++++++++++++ packages/tailscale/src/tailscale.ts | 21 +++++---- 4 files changed, 115 insertions(+), 39 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..b4977173bdf 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -67,4 +67,47 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config JSON is malformed", (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"), "{not json"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); + + 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..10ecfa7fd96 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,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); +const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(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; @@ -45,14 +41,8 @@ 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 parseConfig = (raw: string): Option.Option => + decodeProjectVcsConfigJson(raw); export const make = Effect.fn("makeVcsProjectConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } @@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( + Effect.map(Option.some), Effect.catch((error) => Effect.logWarning("failed to read VCS project config", { configPath, error, - }).pipe(Effect.as(null)), + }).pipe(Effect.as(Option.none())), ), ); - if (raw === null) { + if (Option.isNone(raw)) { return "auto" as const; } - const parsed = parseConfig(raw); - if (parsed === null) { + const parsed = parseConfig(raw.value); + if (Option.isNone(parsed)) { yield* Effect.logWarning("invalid VCS project config", { configPath, }); return "auto" as const; } - return configuredKind(parsed); + return configuredKind(parsed.value); }); const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( @@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { } const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } - - return yield* readConfiguredKind(configPath); + return yield* Option.match(configPath, { + onNone: () => Effect.succeed("auto" as const), + onSome: readConfiguredKind, + }); }); return VcsProjectConfig.of({ diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index dd2b1772fd6..f1d47ad9d21 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -13,6 +15,7 @@ import { parseTailscaleMagicDnsName, parseTailscaleStatus, readTailscaleStatus, + TAILSCALE_STATUS_TIMEOUT, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) }); } +function neverFinishingMockHandle() { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + function mockSpawnerLayer( handler: ( command: string, @@ -112,6 +131,29 @@ describe("tailscale", () => { }); }); + it.effect("times out tailscale status through TestClock", () => { + const layer = Layer.merge( + TestClock.layer(), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle())), + ), + ); + + return Effect.gen(function* () { + const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); + const error = yield* Fiber.join(fiber); + + if (error._tag !== "TailscaleCommandError") { + assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`); + } + assert.equal(error.message, "Tailscale status timed out."); + assert.equal(error.exitCode, null); + }).pipe(Effect.provide(layer)); + }); + it.effect("configures tailscale serve through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e0cca8fde56..c35b2ae03a1 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,6 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; -export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; -export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; -export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; +export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); +export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. @@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect< return yield* parseTailscaleStatus(stdout); }).pipe( Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), + Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), Effect.flatMap((result) => Option.match(result, { onNone: () => @@ -212,7 +213,7 @@ const runTailscaleCommand = ( readonly runMessage: string; readonly exitMessage: (exitCode: number) => string; readonly timeoutMessage: string; - readonly timeoutMs: number; + readonly timeout: Duration.Input; }, ): Effect.Effect => Effect.gen(function* () { @@ -246,7 +247,7 @@ const runTailscaleCommand = ( } }).pipe( Effect.scoped, - Effect.timeoutOption(input.timeoutMs), + Effect.timeoutOption(input.timeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), @@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: { runMessage: "Failed to run tailscale serve.", exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, + timeout: TAILSCALE_SERVE_TIMEOUT, }); }; @@ -284,13 +285,13 @@ export const disableTailscaleServe = ( runMessage: "Failed to run tailscale serve off.", exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, timeoutMessage: "Tailscale serve off timed out.", - timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, + timeout: TAILSCALE_SERVE_TIMEOUT, }); }); export const probeTailscaleHttpsEndpoint = (input: { readonly baseUrl: string; - readonly timeoutMs?: number; + readonly timeout?: Duration.Input; }): Effect.Effect => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -298,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: { const url = new URL("/.well-known/t3/environment", input.baseUrl); const request = HttpClientRequest.get(url.toString()); return yield* client.execute(request); - }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); + }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); return Option.match(response, { onNone: () => false, From d9f59be7044a7f6d193e73ff9fa06127009e0c91 Mon Sep 17 00:00:00 2001 From: Icarus Wings <10465470+TheIcarusWings@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:23:47 +0100 Subject: [PATCH 017/142] feat(sidebar): worktree indicator on session rows (#3057) Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 2 + .../ThreadStatusIndicators.test.tsx | 39 +++++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 37 +++++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/ThreadStatusIndicators.test.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 38d7c362983..0d6b4b6d1c9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, + ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; @@ -743,6 +744,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} + {terminalStatus && ( { + 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 8eac1fa412a..3e85920d190 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,7 +4,7 @@ import { 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 { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; @@ -15,6 +15,7 @@ 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 { @@ -94,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, From c08b968f4e8078027b35752a1bc980cb03ac8fd8 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:48:24 -0700 Subject: [PATCH 018/142] Use Effect schema decoders for JSON parsing (#3060) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge Co-authored-by: codex --- packages/shared/src/dpop.test.ts | 168 ++++++++++++++++++++----------- packages/shared/src/dpop.ts | 110 +++++++++----------- 2 files changed, 156 insertions(+), 122 deletions(-) diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index 58bd161e2a3..c4ba298f66c 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { computeDpopAccessTokenHash, @@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ - ok: true, - thumbprint, - jti: "proof-1", + const result = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (!result.ok) { + assert.fail(result.reason); + } + assert.equal(result.thumbprint, thumbprint); + assert.equal(result.jti, "proof-1"); + }); + + it("rejects malformed DPoP header and payload JSON", () => { + const [header, payload, signature] = proof.split("."); + if (!header || !payload || !signature) { + assert.fail("Expected the test DPoP proof to use compact JWT format."); + } + const malformedJson = Buffer.from("{").toString("base64url"); + + const malformedHeader = verifyDpopProof({ + proof: `${malformedJson}.${payload}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, }); + if (malformedHeader.ok) { + assert.fail("Expected malformed DPoP header JSON to fail."); + } + assert.equal(malformedHeader.reason, "Invalid DPoP JWT header."); + + const malformedPayload = verifyDpopProof({ + proof: `${header}.${malformedJson}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + }); + if (malformedPayload.ok) { + assert.fail("Expected malformed DPoP payload JSON to fail."); + } + assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload."); }); it("rejects method, URL, thumbprint, and time-window mismatches", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( + assert.equal( verifyDpopProof({ proof, method: "GET", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/other", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: "other-thumbprint", - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 1_000, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); + }).ok, + false, + ); }); it("requires the RFC 9449 access token hash when an access token is expected", () => { @@ -122,7 +156,7 @@ describe("verifyDpopProof", () => { accessToken: "clerk-access-token", }); - expect( + assert.equal( verifyDpopProof({ proof: accessTokenProof, method: "POST", @@ -130,32 +164,40 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: true }); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); - expect( - verifyDpopProof({ - proof: accessTokenProof, - method: "POST", - url: "https://example.com/v1/environments/env/connect", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "other-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + }).ok, + true, + ); + + const missingHash = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "clerk-access-token", + }); + if (missingHash.ok) { + assert.fail("Expected DPoP proof without an access token hash to fail."); + } + assert.equal(missingHash.reason, "DPoP access token hash mismatch."); + + const mismatchedHash = verifyDpopProof({ + proof: accessTokenProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "other-access-token", + }); + if (mismatchedHash.ok) { + assert.fail("Expected DPoP proof with a mismatched access token hash to fail."); + } + assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch."); }); it("normalizes htu by excluding query and fragment components per RFC 9449", () => { - expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( + assert.equal( + normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"), "https://example.com/v1/environments/env/connect", ); @@ -168,15 +210,16 @@ describe("verifyDpopProof", () => { publicJwk, }); - expect( + assert.equal( verifyDpopProof({ proof: queryProof, method: "POST", url: "https://example.com/v1/environments/env/connect?foo=bar#frag", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: true }); + }).ok, + true, + ); }); it("rejects DPoP public JWK headers that expose private key material", () => { @@ -192,14 +235,17 @@ describe("verifyDpopProof", () => { publicJwk: privateJwk, }); - expect( - verifyDpopProof({ - proof: proofWithPrivateJwk, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); + const result = verifyDpopProof({ + proof: proofWithPrivateJwk, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (result.ok) { + assert.fail("Expected DPoP proof with private JWK material to fail."); + } + assert.equal(result.reason, "Invalid DPoP JWT header."); }); }); diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 34210679007..88dcf8e3090 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,6 +1,7 @@ import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({ y: Schema.String.check(Schema.isNonEmpty()), }); export type DpopPublicJwk = typeof DpopPublicJwk.Type; -const isDpopPublicJwk = Schema.is(DpopPublicJwk); -interface DpopJwtHeader { - readonly typ: string; - readonly alg: string; - readonly jwk: DpopPublicJwk; -} +const DpopJwtHeaderPublicJwk = Schema.Struct({ + ...DpopPublicJwk.fields, + d: Schema.optionalKey(Schema.Never), +}); -interface DpopJwtPayload { - readonly htm: string; - readonly htu: string; - readonly jti: string; - readonly iat: number; - readonly ath?: string; -} +const DpopJwtHeaderJson = Schema.fromJsonString( + Schema.Struct({ + typ: Schema.Literal(DPOP_TYP), + alg: Schema.Literal(DPOP_ALG), + jwk: DpopJwtHeaderPublicJwk, + }), +); +const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson); + +const DpopJwtPayloadJson = Schema.fromJsonString( + Schema.Struct({ + htm: Schema.String.check(Schema.isNonEmpty()), + htu: Schema.String.check(Schema.isNonEmpty()), + jti: Schema.String.check(Schema.isNonEmpty()), + iat: Schema.Int, + ath: Schema.optionalKey(Schema.String), + }), +); +const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson); export type DpopVerificationResult = | { @@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function decodeBase64UrlJson(value: string): unknown { - return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; +function decodeBase64UrlDpopJwtHeader(value: string) { + return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } -function isDpopJwtHeader(value: unknown): value is DpopJwtHeader { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - record.typ === DPOP_TYP && - record.alg === DPOP_ALG && - typeof record.jwk === "object" && - record.jwk !== null && - !("d" in record.jwk) && - isDpopPublicJwk(record.jwk) - ); -} - -function isDpopJwtPayload(value: unknown): value is DpopJwtPayload { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - typeof record.htm === "string" && - record.htm.length > 0 && - typeof record.htu === "string" && - record.htu.length > 0 && - typeof record.jti === "string" && - record.jti.length > 0 && - typeof record.iat === "number" && - Number.isInteger(record.iat) - ); +function decodeBase64UrlDpopJwtPayload(value: string) { + return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } function dpopThumbprintInput(jwk: DpopPublicJwk): string { @@ -145,53 +128,58 @@ export function verifyDpopProof(input: { } try { - const header = decodeBase64UrlJson(parts[0]); - const payload = decodeBase64UrlJson(parts[1]); - if (!isDpopJwtHeader(header)) { + const header = decodeBase64UrlDpopJwtHeader(parts[0]); + const payload = decodeBase64UrlDpopJwtPayload(parts[1]); + if (Option.isNone(header)) { return { ok: false, reason: "Invalid DPoP JWT header." }; } - if (!isDpopJwtPayload(payload)) { + if (Option.isNone(payload)) { return { ok: false, reason: "Invalid DPoP JWT payload." }; } - const thumbprint = computeDpopJwkThumbprint(header.jwk); + const thumbprint = computeDpopJwkThumbprint(header.value.jwk); if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { return { ok: false, reason: "DPoP key thumbprint mismatch." }; } - if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { + if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { return { ok: false, reason: "DPoP method mismatch." }; } const normalizedHtu = normalizeDpopHtu(input.url); - if (normalizedHtu === null || payload.htu !== normalizedHtu) { + if (normalizedHtu === null || payload.value.htu !== normalizedHtu) { return { ok: false, reason: "DPoP URL mismatch." }; } if (input.expectedAccessToken) { const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); - if (payload.ath !== expectedAth) { + if (payload.value.ath !== expectedAth) { return { ok: false, reason: "DPoP access token hash mismatch." }; } } const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; if ( - payload.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.iat > maxAgeSeconds + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds ) { return { ok: false, reason: "DPoP proof is outside the allowed time window." }; } const signature = base64UrlToBytes(parts[2]); const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); - const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { - prehash: false, - format: "compact", - }); + const verified = p256.verify( + signature, + signatureInputHash, + publicKeyBytesFromJwk(header.value.jwk), + { + prehash: false, + format: "compact", + }, + ); return verified ? { ok: true, thumbprint, - jti: payload.jti, - iat: payload.iat, + jti: payload.value.jti, + iat: payload.value.iat, } : { ok: false, reason: "Invalid DPoP signature." }; } catch { From fbf6263876fd9b6f8bcbbf6b9ea097ed8fc7cb60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 12:14:06 -0700 Subject: [PATCH 019/142] Only show enabled providers in picker sidebar (#3168) Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 8 +- .../components/chat/ModelPickerContent.tsx | 14 ++- .../components/chat/ModelPickerSidebar.tsx | 94 ++----------------- .../components/settings/SettingsPanels.tsx | 3 +- apps/web/src/providerInstances.test.ts | 76 +++++++++++++++ apps/web/src/providerInstances.ts | 45 +++++++++ 6 files changed, 146 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7c25a8a1f75..3a5e06bce06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -105,6 +105,7 @@ import { import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, @@ -662,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 = diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7c138b294df..f357218c22a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -22,7 +22,11 @@ import { import { useSettings, useUpdateSettings } 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 = { @@ -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/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5ecf009a08f..20ebafbce40 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -44,6 +44,7 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; @@ -495,7 +496,7 @@ export function GeneralSettingsPanel() { const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelInstanceEntries = sortProviderInstanceEntries( - deriveProviderInstanceEntries(serverProviders), + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ); const textGenInstanceEntry = gitModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index cf10aaefb74..a5b03f56328 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -1,7 +1,10 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + applyProviderInstanceSettings, deriveProviderInstanceEntries, + isProviderInstancePickerReady, + isProviderInstancePickerVisible, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; @@ -30,6 +33,79 @@ function provider(input: { }; } +describe("isProviderInstancePickerReady", () => { + it("rejects a disabled instance even while its last probe status is ready", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + enabled: false, + }), + ]); + + expect(entry?.status).toBe("ready"); + expect(entry && isProviderInstancePickerReady(entry)).toBe(false); + }); + + it("accepts an enabled, available, ready instance", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + + expect(entry && isProviderInstancePickerReady(entry)).toBe(true); + }); +}); + +describe("isProviderInstancePickerVisible", () => { + it("keeps enabled instances in the rail and removes disabled instances", () => { + const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + enabled: false, + }), + ]); + + expect(enabledEntry && isProviderInstancePickerVisible(enabledEntry)).toBe(true); + expect(disabledEntry && isProviderInstancePickerVisible(disabledEntry)).toBe(false); + }); +}); + +describe("applyProviderInstanceSettings", () => { + it("uses settings when a streamed snapshot still reports a disabled default as enabled", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + }, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }); + + it("treats a removed custom instance snapshot as disabled", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_work", + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }); +}); + describe("deriveProviderInstanceEntries", () => { it("uses explicit instance id and driver kind from the snapshot", () => { const snapshot = provider({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 3ec67c9e25e..c9ac87ac39f 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -19,6 +19,7 @@ import { type ProviderInstanceId, type ServerProvider, type ServerProviderModel, + type ServerSettings, type ServerProviderState, } from "@t3tools/contracts"; @@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { readonly models: ReadonlyArray; } +/** + * Whether an instance can currently contribute models to an interactive picker. + * + * Disabling an instance updates `enabled` independently, while its previous + * `ready` probe status can remain in the streamed snapshot until reconciliation. + */ +export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { + return entry.enabled && entry.isAvailable && entry.status === "ready"; +} + +/** Picker rails contain configured, enabled instances only. */ +export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { + return entry.enabled; +} + /** * Turn an instance id slug into a human-readable label. Splits on `_` / `-` * and camelCase boundaries and title-cases each token, so `codex_personal` @@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( }); } +/** + * Overlay the current settings configuration onto streamed provider snapshots. + * Provider probes can briefly retain their previous `enabled` value after a + * settings write, so picker visibility must follow settings rather than waiting + * for probe reconciliation. + * + * Non-default instances only exist through `providerInstances`; if one is + * absent there, its streamed snapshot is stale (for example immediately after + * deletion) and is treated as disabled. + */ +export function applyProviderInstanceSettings( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + const legacyProviders = settings.providers as Readonly< + Record + >; + + return entries.map((entry) => { + const explicitInstance = settings.providerInstances?.[entry.instanceId]; + const enabled = explicitInstance + ? (explicitInstance.enabled ?? true) + : entry.isDefault + ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) + : false; + return enabled === entry.enabled ? entry : { ...entry, enabled }; + }); +} + /** * Sort instance entries so the default instance of each driver kind appears * before any custom instances of the same kind. Within a kind, custom From cdaba7fa8b9c94dba783b7161e265a6db03b3d93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 13:15:19 -0700 Subject: [PATCH 020/142] Share thread state idle TTL across client atoms (#3163) --- .../client-runtime/src/state/threadDetail.ts | 22 ++++++++-------- .../src/state/threadRetention.ts | 3 +++ .../src/state/threads-atoms.test.ts | 26 +++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 12 ++++++--- 4 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 packages/client-runtime/src/state/threadRetention.ts create mode 100644 packages/client-runtime/src/state/threads-atoms.test.ts diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 20caf4a05af..f048573c2ef 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -15,12 +15,12 @@ import type { EnvironmentThread, EnvironmentThreadShell } from "./models.ts"; import { scopeThread } from "./models.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); const EMPTY_CHECKPOINTS: ReadonlyArray = Object.freeze([]); -const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000; /** * Combine detail-only collections with the shell's authoritative thread metadata. @@ -75,7 +75,7 @@ export function createEnvironmentThreadDetailAtoms( () => EMPTY_ENVIRONMENT_THREAD_STATE, ), ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-state-value:${key}`), ); }); @@ -93,21 +93,21 @@ export function createEnvironmentThreadDetailAtoms( previousValue = source === null ? null : scopeThread(ref.environmentId, source); return previousValue; }).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-detail:${key}`), ); }); const threadStatusAtomFamily = Atom.family((key: string) => Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-status:${key}`), ), ); const threadErrorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-error:${key}`), ), ); @@ -117,7 +117,7 @@ export function createEnvironmentThreadDetailAtoms( (get): ReadonlyArray => get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-messages:${key}`), ), ); @@ -127,7 +127,7 @@ export function createEnvironmentThreadDetailAtoms( (get): ReadonlyArray => get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-activities:${key}`), ), ); @@ -137,7 +137,7 @@ export function createEnvironmentThreadDetailAtoms( (get): ReadonlyArray => get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-proposed-plans:${key}`), ), ); @@ -147,7 +147,7 @@ export function createEnvironmentThreadDetailAtoms( (get): ReadonlyArray => get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-checkpoints:${key}`), ), ); @@ -156,7 +156,7 @@ export function createEnvironmentThreadDetailAtoms( Atom.make( (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-session:${key}`), ), ); @@ -165,7 +165,7 @@ export function createEnvironmentThreadDetailAtoms( Atom.make( (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, ).pipe( - Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), Atom.withLabel(`environment-thread-latest-turn:${key}`), ), ); diff --git a/packages/client-runtime/src/state/threadRetention.ts b/packages/client-runtime/src/state/threadRetention.ts new file mode 100644 index 00000000000..119b963167c --- /dev/null +++ b/packages/client-runtime/src/state/threadRetention.ts @@ -0,0 +1,3 @@ +// Mobile thread routes unmount during back navigation. Retain the stream-backed +// state across short subscriber gaps without keeping every opened thread alive. +export const THREAD_STATE_IDLE_TTL_MS = 5 * 60_000; diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts new file mode 100644 index 00000000000..420f9412b68 --- /dev/null +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -0,0 +1,26 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; +import { createEnvironmentThreadStateAtoms } from "./threads.ts"; + +describe("createEnvironmentThreadStateAtoms", () => { + it("retains thread state across short subscriber gaps", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry | EnvironmentCacheStore, + never + >; + const threads = createEnvironmentThreadStateAtoms(runtime); + const environmentId = EnvironmentId.make("environment-1"); + const threadId = ThreadId.make("thread-1"); + const atom = threads.stateAtom(environmentId, threadId); + + expect(atom.idleTTL).toBe(THREAD_STATE_IDLE_TTL_MS); + expect(threads.stateAtom(environmentId, threadId)).toBe(atom); + expect(threads.stateAtom(environmentId, ThreadId.make("thread-2"))).not.toBe(atom); + }); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 44b137f937c..1e2505ac503 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -21,6 +21,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; +import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; @@ -249,9 +250,14 @@ export function createEnvironmentThreadStateAtoms( ) { const family = Atom.family((key: string) => { const { environmentId, threadId } = parseThreadAtomKey(key); - return runtime.atom(threadStateChanges(environmentId, threadId), { - initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, - }); + return runtime + .atom(threadStateChanges(environmentId, threadId), { + initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, + }) + .pipe( + Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-state:${key}`), + ); }); return { From e29ad7604b1597e3a741868c85ffd76bca84fa7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 16:49:35 -0700 Subject: [PATCH 021/142] Unify mobile typography tokens across the app (#3162) Co-authored-by: codex --- apps/mobile/.swiftlint.yml | 1 + apps/mobile/global.css | 22 ++++++++++- .../ios/T3ComposerEditorView.swift | 31 ++++++++++++++-- apps/mobile/src/app/+not-found.tsx | 4 +- apps/mobile/src/app/connections/index.tsx | 2 +- apps/mobile/src/app/connections/new.tsx | 10 ++--- apps/mobile/src/app/new/index.tsx | 12 +++--- apps/mobile/src/app/settings/environments.tsx | 22 +++++------ apps/mobile/src/app/settings/index.tsx | 14 +++---- apps/mobile/src/components/AppText.tsx | 2 +- apps/mobile/src/components/BrandMark.tsx | 9 ++--- .../src/components/ComposerToolbarTrigger.tsx | 2 +- apps/mobile/src/components/ControlPill.tsx | 2 +- apps/mobile/src/components/EmptyState.tsx | 4 +- apps/mobile/src/components/ErrorBanner.tsx | 2 +- apps/mobile/src/components/StatusPill.tsx | 2 +- .../archive/ArchivedThreadsScreen.tsx | 20 +++++----- .../cloud/CloudWaitlistEnrollment.tsx | 20 ++++------ .../connection/ConnectionEnvironmentRow.tsx | 21 +++++------ .../connection/ConnectionSheetButton.tsx | 2 +- .../EnvironmentConnectionNotice.tsx | 6 +-- .../features/files/FileMarkdownPreview.tsx | 7 ++-- .../src/features/files/FileTreeBrowser.tsx | 14 +++---- .../src/features/files/SourceFileSurface.tsx | 24 +++++++----- .../features/files/ThreadFilesRouteScreen.tsx | 15 ++++---- .../files/WorkspaceFileImagePreview.tsx | 4 +- .../files/WorkspaceFileWebPreview.tsx | 8 ++-- .../files/nativeSourceFileAdapter.test.ts | 20 ++++++++++ .../features/files/nativeSourceFileAdapter.ts | 18 +++++---- apps/mobile/src/features/home/HomeHeader.tsx | 5 ++- apps/mobile/src/features/home/HomeScreen.tsx | 14 +++---- .../features/home/thread-swipe-actions.tsx | 2 +- .../features/projects/AddProjectScreen.tsx | 22 +++++------ .../review/ReviewCommentComposerSheet.tsx | 16 ++++---- .../src/features/review/ReviewSheet.tsx | 37 ++++++++++--------- .../review/nativeReviewDiffAdapter.ts | 13 ++++--- .../features/review/reviewDiffRendering.tsx | 19 +++++++--- .../terminal/NativeTerminalSurface.tsx | 11 +++--- .../features/terminal/ThreadTerminalPanel.tsx | 6 +-- .../terminal/ThreadTerminalRouteScreen.tsx | 5 ++- .../threads/ComposerCommandPopover.tsx | 17 +++++++-- .../threads/GitActionProgressOverlay.tsx | 4 +- .../features/threads/NewTaskDraftScreen.tsx | 3 +- .../features/threads/PendingApprovalCard.tsx | 2 +- .../features/threads/PendingUserInputCard.tsx | 8 ++-- .../src/features/threads/ThreadComposer.tsx | 11 +++--- .../src/features/threads/ThreadFeed.tsx | 26 ++++++------- .../threads/ThreadNavigationDrawer.tsx | 12 +++--- .../features/threads/ThreadRouteScreen.tsx | 5 ++- .../features/threads/git/GitBranchesSheet.tsx | 22 +++++------ .../features/threads/git/GitCommitSheet.tsx | 36 +++++++++--------- .../features/threads/git/GitConfirmSheet.tsx | 6 +-- .../features/threads/git/GitOverviewSheet.tsx | 6 +-- .../threads/git/gitSheetComponents.tsx | 10 ++--- .../src/features/threads/thread-work-log.tsx | 6 +-- apps/mobile/src/lib/typography.test.ts | 23 ++++++++++++ apps/mobile/src/lib/typography.ts | 22 +++++++++++ .../src/native/T3ComposerEditor.ios.tsx | 11 +++++- apps/mobile/src/native/T3ComposerEditor.tsx | 4 +- 59 files changed, 415 insertions(+), 289 deletions(-) create mode 100644 apps/mobile/src/lib/typography.test.ts create mode 100644 apps/mobile/src/lib/typography.ts 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/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/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/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 cf1f6a7f7e5..ca9693dbb19 100644 --- a/apps/mobile/src/app/connections/new.tsx +++ b/apps/mobile/src/app/connections/new.tsx @@ -171,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 @@ -202,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 @@ -220,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/new/index.tsx b/apps/mobile/src/app/new/index.tsx index dbde9c4a412..6e2aa64ce11 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/app/new/index.tsx @@ -129,10 +129,10 @@ export default function NewTaskRoute() { {items.length === 0 ? ( {projectEmptyState.loading ? : null} - + {projectEmptyState.title} - + {projectEmptyState.detail} {!catalogState.hasReadyEnvironment ? ( @@ -140,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 @@ -149,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 @@ -197,9 +197,7 @@ export default function NewTaskRoute() { /> - - {item.title} - + {item.title} - + No environments connected yet.{"\n"}Tap{" "} + to add one. @@ -160,7 +160,7 @@ function ConfiguredCloudEnvironmentRows(props: { return ( - T3 Cloud + T3 Cloud - + Loading linked cloud environments. ) : controller.relayDiscovery.error ? ( - + Could not load T3 Cloud environments - + {controller.relayDiscovery.error} {controller.relayDiscovery.errorTraceId ? ( @@ -222,7 +222,7 @@ function ConfiguredCloudEnvironmentRows(props: { ) : ( - + No additional linked cloud environments. @@ -361,7 +361,7 @@ function CloudEnvironmentRowShell(props: { {props.label} @@ -370,7 +370,7 @@ function CloudEnvironmentRowShell(props: { {props.connectionError ? ( @@ -384,7 +384,7 @@ function CloudEnvironmentRowShell(props: { className="min-w-0 flex-row items-start gap-1" > {statusText} @@ -394,7 +394,7 @@ function CloudEnvironmentRowShell(props: { { event.stopPropagation(); copyTextWithHaptic(errorTraceId); @@ -446,7 +446,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" > - Copy trace ID + Copy trace ID ); } diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index eae7c5fa33a..41799ae7b8b 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -346,7 +346,7 @@ function ConfiguredSettingsRouteScreen() { onPress={openAccount} /> - + T3 Code works locally without signing in. Cloud features are optional. @@ -389,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 ); @@ -444,13 +444,13 @@ function SettingsRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - + {props.label} {props.value ? ( @@ -502,7 +502,7 @@ function SettingsSwitchRow(props: { style={{ opacity: props.disabled ? 0.45 : 1 }} > - {props.label} + {props.label} - + 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/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/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index ecdd9990186..3e1934100cd 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -147,14 +147,14 @@ function ProjectGroupLabel(props: { workspaceRoot={props.project.workspaceRoot} /> {props.project.title} {props.environmentLabel ? ( - + {props.environmentLabel} ) : null} @@ -265,13 +265,13 @@ function ArchivedThreadRow(props: { {props.thread.title} {timestamp} @@ -286,7 +286,7 @@ function ArchivedThreadRow(props: { type="monochrome" /> @@ -314,10 +314,12 @@ function ArchivedThreadRow(props: { function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) { return ( - Could not load every archive - {props.message} + + Could not load every archive + + {props.message} - Try again + Try again ); @@ -386,7 +388,7 @@ export function ArchivedThreadsScreen(props: { {isInitialLoad ? ( - Loading archive… + Loading archive… ) : props.groups.length === 0 ? ( 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/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 5c3e290fa22..f5aa26be960 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -76,19 +76,16 @@ export function ConnectionEnvironmentRow(props: { /> - + {props.environment.environmentLabel} - + {props.environment.displayUrl} {statusLabel ? ( {props.environment.isRelayManaged ? ( - + Managed by T3 Cloud. Tunnel details update automatically. ) : ( <> Label @@ -156,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 @@ -175,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" /> @@ -189,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/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 15852cc3c88..9b8c96d25ea 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -73,10 +73,10 @@ export function EnvironmentConnectionNotice(props: { /> )} - + {noticeTitle(props.connection.phase, props.environmentLabel)} - + {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} {props.connection.traceId ? ( <> @@ -99,7 +99,7 @@ export function EnvironmentConnectionNotice(props: { className="mt-1 rounded-full bg-subtle px-4 py-2.5 active:opacity-70" onPress={props.onRetry} > - Retry now + Retry now ) : null} diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 1f6720cb7db..469a4c983a9 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -7,6 +7,7 @@ import { } from "react-native-nitro-markdown"; import { Linking, ScrollView, Text as NativeText, View } from "react-native"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; import { hasNativeSelectableMarkdownText, @@ -72,8 +73,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { text: { color: body, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, }, heading: { color: strong, @@ -123,8 +123,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { skillTextColor: codeText, quoteMarkerColor: blockquoteBorder, dividerColor: horizontalRule, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index ff9577a4adb..3def77433b2 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -64,7 +64,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { {node.kind === "directory" ? ( - + {node.children.length} ) : null} @@ -142,10 +142,8 @@ export function FileTreeBrowser(props: { {props.error && props.entries.length === 0 ? ( - Files unavailable - - {props.error} - + Files unavailable + {props.error} ) : ( ) : ( <> - No files found - + 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 index 5f3647d735f..b96d6515951 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -18,6 +18,7 @@ import { } from "../review/reviewDiffRendering"; import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { cn } from "../../lib/cn"; +import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { buildNativeSourceRows, buildNativeSourceTokens, @@ -28,8 +29,8 @@ import { } from "./nativeSourceFileAdapter"; import { sourceHighlightAtom } from "./sourceHighlightingState"; -const SOURCE_LINE_HEIGHT = 24; -const SOURCE_LINE_NUMBER_WIDTH = 58; +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 { @@ -56,10 +57,12 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { style={{ minHeight: SOURCE_LINE_HEIGHT }} > {props.index + 1} @@ -67,8 +70,13 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { {props.tokens && props.tokens.length > 0 ? (() => { @@ -80,7 +88,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { const fontWeight = token.fontStyle !== null && (token.fontStyle & 2) === 2 ? ("700" as const) - : ("500" as const); + : ("400" as const); const fontStyle = token.fontStyle !== null && (token.fontStyle & 1) === 1 ? ("italic" as const) @@ -142,9 +150,7 @@ function SourceHighlightStatusView(props: { readonly status: SourceHighlightStat if (props.status === "error") { return ( - - Plain text - + Plain text ); } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index f730e4616ac..5cd7fe9d02b 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -24,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { cn } from "../../lib/cn"; 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"; @@ -100,7 +101,7 @@ function ModeButton(props: { @@ -187,7 +188,7 @@ function FileBreadcrumbs(props: { readonly projectName: string; readonly relativ ) : null} - Loading file... + Loading file... ); } @@ -317,10 +318,10 @@ function FileContent(props: { {props.truncated ? ( - + Partial file - + Preview limited to the first 1 MB of a truncated file. @@ -389,7 +390,7 @@ function FilesHeaderTitle(props: { readonly projectName: string }) { style={{ color: foregroundColor, fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", letterSpacing: -0.4, }} @@ -401,7 +402,7 @@ function FilesHeaderTitle(props: { readonly projectName: string }) { style={{ color: secondaryForegroundColor, fontFamily: "DMSans_500Medium", - fontSize: 12, + fontSize: MOBILE_TYPOGRAPHY.label.fontSize, fontWeight: "500", letterSpacing: 0.2, }} diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 15d5b76717d..73eca66bf99 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -81,7 +81,7 @@ function CachedWorkspaceFileImagePreview(props: { return ( - Loading image... + Loading image... ); } @@ -102,7 +102,7 @@ export function WorkspaceFileImagePreview(props: { return ( - + Preparing image preview... diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index 1628c4601d0..6d03a23d52a 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -13,7 +13,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) return ( - Preparing preview... + Preparing preview... ); } @@ -23,10 +23,8 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) {loadProgress > 0 && loadProgress < 1 ? : null} {loadError ? ( - Preview failed - - {loadError} - + Preview failed + {loadError} ) : null} { + 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([ { diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 19abc802146..9bb341e2909 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -3,22 +3,24 @@ import type { 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 = 24; +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: 58, - codePadding: 8, - codeFontSize: 13, - codeFontWeight: "medium", - lineNumberFontSize: 11, - lineNumberFontWeight: "medium", - emptyStateFontSize: 12, + 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", }; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 839053523a6..9757d5fbf91 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -12,6 +12,7 @@ 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 { @@ -118,7 +119,7 @@ export function HomeHeader(props: { @@ -196,7 +196,7 @@ function ProjectGroupLabel(props: { {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -335,7 +335,7 @@ function ThreadRow(props: { {props.thread.title} @@ -345,12 +345,12 @@ function ThreadRow(props: { className={tone.pillClassName} style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} > - + {tone.label} {timestamp} @@ -367,7 +367,7 @@ function ThreadRow(props: { type="monochrome" /> @@ -429,7 +429,7 @@ function StaleCatalogStatusPill(props: { weight="semibold" /> )} - + {label} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index faedaed7cee..dd0e2901bba 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -167,7 +167,7 @@ function SwipeActionButton(props: { - {props.label} + {props.label} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 11ff7288d61..fa1f635de8d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -100,7 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( {props.children} @@ -168,9 +168,9 @@ function ListRow(props: { {props.icon} - {props.title} + {props.title} {props.subtitle ? ( - + {props.subtitle} ) : null} @@ -202,7 +202,7 @@ function PrimaryActionButton(props: { {props.loading ? ( ) : ( - {props.label} + {props.label} )} ); @@ -215,7 +215,7 @@ function ProjectPathInput(props: { }) { 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 ); @@ -565,7 +565,7 @@ export function AddProjectRepositoryScreen() { {error ? : null} : 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 c02120b2619..92203c0ed4e 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -20,6 +20,7 @@ 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"; @@ -41,10 +42,10 @@ const REVIEW_HEADER_SPACING = 0; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - + Partial diff - + {props.notice} @@ -69,7 +70,7 @@ function ReviewSelectionActionBar(props: { tintColor="#ffffff" type="monochrome" /> - {props.title} + {props.title} ); @@ -218,8 +219,8 @@ export function ReviewSheet() { if (error) { children.push( - Review unavailable - {error} + Review unavailable + {error} , ); } @@ -251,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, @@ -273,7 +274,7 @@ export function ReviewSheet() { - 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/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/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 b29a72c54b4..5d0b0547e6e 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -127,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 b0361f05575..8e9a47a58b5 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -25,6 +25,7 @@ import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceState } from "../../state/workspace"; import { buildThreadTerminalNavigation } from "../../lib/routes"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAttachedTerminalSession, useKnownTerminalSessions, @@ -920,7 +921,7 @@ export function ThreadTerminalRouteScreen() { style={{ color: terminalTheme.foreground, fontFamily: "DMSans_700Bold", - fontSize: 13, + fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, lineHeight: 16, }} > @@ -932,7 +933,7 @@ export function ThreadTerminalRouteScreen() { style={{ color: terminalTheme.mutedForeground, fontFamily: "Menlo", - fontSize: 11, + fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, lineHeight: 14, }} > 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..bda966cf16e 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -73,12 +73,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 8a93989f3d9..92c13c20070 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,6 +30,7 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; @@ -430,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 79a01cdada3..0617ef1cbcf 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -16,7 +16,7 @@ export interface PendingApprovalCardProps { 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 5bd0d4e4a3c..c9e01777214 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -26,7 +26,7 @@ export interface PendingUserInputCardProps { 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 edac061daec..0050eb923be 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -43,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, @@ -189,7 +190,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( )} {props.status.label} @@ -717,8 +718,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } } textStyle={{ - fontSize: 15, - lineHeight: isExpanded ? 22 : 20, + ...MOBILE_TYPOGRAPHY.composer, color: foregroundColor, fontFamily: "DMSans_400Regular", }} @@ -751,7 +751,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer justifyContent: "center", }} > - + +{props.draftAttachments.length - 3} @@ -831,8 +831,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 55863557139..7424d856368 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -56,6 +56,7 @@ import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; import type { LayoutVariant } from "../../lib/layout"; import { buildThreadFilesNavigation } from "../../lib/routes"; +import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import { @@ -444,8 +445,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe marginRight: 5, color: inlineTextColor, fontFamily: "DMSans_400Regular", - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, textAlign: ordered ? "right" : "center", }} > @@ -466,7 +466,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: inlineCodeTextColor, fontFamily: "ui-monospace", - fontSize: 12, + fontSize: MOBILE_TYPOGRAPHY.label.fontSize, lineHeight: 22, }} > @@ -503,7 +503,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: markdownBodyColor, fontFamily: "ui-monospace", - fontSize: 12, + fontSize: MOBILE_TYPOGRAPHY.label.fontSize, opacity: 0.7, textTransform: "uppercase", }} @@ -523,7 +523,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe style={{ color: blockTextColor, fontFamily: "ui-monospace", - fontSize: 12, + fontSize: MOBILE_TYPOGRAPHY.label.fontSize, lineHeight: 18, }} > @@ -603,8 +603,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe skillTextColor: "#f0abfc", quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -633,8 +632,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, - fontSize: 15, - lineHeight: 22, + ...MOBILE_TYPOGRAPHY.body, fontFamily: "DMSans_400Regular", headingFontFamily: "DMSans_700Bold", boldFontFamily: "DMSans_700Bold", @@ -821,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 ? ( @@ -995,7 +993,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { @@ -1040,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()} @@ -1052,7 +1050,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { {props.comment.text} diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 77a80fff550..9318fb76017 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -152,7 +152,7 @@ export function ThreadNavigationDrawer(props: { ]} > - Threads + Threads { props.onClose(); @@ -224,7 +224,7 @@ function ThreadNavigationDrawerContent(props: { {groupedThreads.map((group) => ( {group.title} @@ -233,9 +233,7 @@ function ThreadNavigationDrawerContent(props: { {group.threads.length === 0 ? ( - - No threads yet - + No threads yet ) : ( group.threads.map((thread, index) => { @@ -260,11 +258,11 @@ function ThreadNavigationDrawerContent(props: { > - + {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 51f5832f50f..7bb74ae88ff 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -20,6 +20,7 @@ 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 { @@ -390,7 +391,7 @@ export function ThreadRouteScreen() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 18, + fontSize: MOBILE_TYPOGRAPHY.headline.fontSize, fontWeight: "900", color: foregroundColor, letterSpacing: -0.4, @@ -402,7 +403,7 @@ export function ThreadRouteScreen() { numberOfLines={1} style={{ fontFamily: "DMSans_700Bold", - fontSize: 12, + fontSize: MOBILE_TYPOGRAPHY.label.fontSize, fontWeight: "700", color: secondaryFg, letterSpacing: 0.3, diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 3fbea89ba32..e27136702f2 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -69,7 +69,7 @@ export function GitBranchesSheet() { > New branch @@ -78,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, @@ -104,7 +104,7 @@ export function GitBranchesSheet() { New worktree @@ -113,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, @@ -125,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, @@ -154,18 +154,16 @@ export function GitBranchesSheet() { Existing branches {branchesLoading ? ( - - Loading branches... - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -195,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 9e20f5b1560..76e0daf5f0a 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -79,14 +79,14 @@ export function GitCommitSheet() { > - Branch - + Branch + {gitStatus.data?.refName ?? "(detached HEAD)"} {isDefaultRef ? ( Warning: this is the default branch. @@ -97,8 +97,8 @@ export function GitCommitSheet() { - Files - + Files + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} @@ -108,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"} @@ -123,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} @@ -177,21 +177,21 @@ export function GitCommitSheet() { {file.path} {!included ? ( - + Excluded from this commit ) : null} - + +{file.insertions} - + -{file.deletions} @@ -204,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 314d0cfcd20..d6255a296b7 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -175,13 +175,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/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 244998eb336..707e1a24f0d 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -98,7 +98,7 @@ export function ThreadWorkLog(props: { return ( {!onlyToolRows ? ( - + work log ) : null} @@ -164,7 +164,7 @@ export function ThreadWorkLog(props: { {props.copiedRowId === row.id ? ( - + Copied ) : null} @@ -209,7 +209,7 @@ export function ThreadWorkLog(props: { > {row.fullDetail} 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, From 753bc4672ded1138147ba3500063d14c0e1d3665 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 17:38:45 -0700 Subject: [PATCH 022/142] Harden preview ownership and option-based secret handling (#3172) --- apps/server/src/mcp/McpHttpServer.test.ts | 10 +- .../src/mcp/PreviewAutomationBroker.test.ts | 142 ++++- .../server/src/mcp/PreviewAutomationBroker.ts | 65 ++- apps/server/src/ws.ts | 4 +- .../preview/PreviewAutomationOwner.test.ts | 4 +- .../preview/PreviewAutomationOwner.tsx | 185 +++---- .../previewAutomationRequestConsumer.test.ts | 81 +++ .../previewAutomationRequestConsumer.ts | 83 +++ packages/client-runtime/src/state/preview.ts | 4 + packages/contracts/src/ipc.ts | 3 +- packages/contracts/src/previewAutomation.ts | 7 +- packages/contracts/src/rpc.ts | 5 +- pnpm-lock.yaml | 502 +++++++++--------- 13 files changed, 690 insertions(+), 405 deletions(-) create mode 100644 apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts create mode 100644 apps/web/src/components/preview/previewAutomationRequestConsumer.ts diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 25509dc593f..210bb7e5ad8 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -107,7 +107,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/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 353353aaef2..06b18259833 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 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, @@ -68,22 +73,119 @@ 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 requests = yield* broker.connect( + makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); + +it.effect("lets the browser host resolve an active tab that has not been reported yet", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.__testing.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.__testing.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.__testing.make; + const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.reportOwner({ - clientId: "client-hidden", + + yield* broker.clearOwner({ + clientId: "client-1", environmentId: scope.environmentId, - threadId: 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.__testing.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.__testing.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..3cd7563bd9d 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, @@ -35,11 +36,13 @@ export interface PreviewAutomationInvokeInput { } export interface PreviewAutomationBrokerShape { - readonly connect: (clientId: string) => Effect.Effect>; + readonly connect: ( + owner: PreviewAutomationOwner, + ) => Effect.Effect>; readonly reportOwner: ( owner: PreviewAutomationOwner, ) => Effect.Effect; - readonly clearOwner: (clientId: string) => Effect.Effect; + readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; readonly respond: ( response: PreviewAutomationResponse, ) => Effect.Effect; @@ -63,7 +66,7 @@ interface ClientConnection { } interface PendingRequest { - readonly clientId: string; + readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; } @@ -133,17 +136,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); } @@ -166,12 +168,22 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { const connect: PreviewAutomationBrokerShape["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))); @@ -189,10 +201,18 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { const clearOwner: PreviewAutomationBrokerShape["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 }; }); }); @@ -234,8 +254,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 +271,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) => { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1ad37e7c49b..29ade95d395 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1489,7 +1489,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) => @@ -1507,7 +1507,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) => diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts index df5d9944793..fe11ea75aa6 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts +++ b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; describe("observeAutomationOwnerConnectedGeneration", () => { - it("re-reports ownership only after a later transport generation connects", () => { + it("reports ownership when the initial transport generation connects", () => { const initial = observeAutomationOwnerConnectedGeneration(null, 1); expect(initial).toEqual({ nextGeneration: 1, - shouldReport: false, + shouldReport: true, }); const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx index a1b24cd5553..0264cf7a01f 100644 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -1,15 +1,16 @@ "use client"; +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 { applyPreviewServerSnapshot, @@ -20,11 +21,14 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; import { previewEnvironment } from "~/state/preview"; -import { useEnvironmentQuery } from "~/state/query"; 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, @@ -41,7 +45,7 @@ export function observeAutomationOwnerConnectedGeneration( } return { nextGeneration: connectedGeneration, - shouldReport: previousGeneration !== null && previousGeneration !== connectedGeneration, + shouldReport: previousGeneration !== connectedGeneration, }; } @@ -109,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: { @@ -135,12 +125,22 @@ export function PreviewAutomationOwner(props: { }) { const { threadRef, visible } = props; const automationClientId = useId(); - const automationRequests = useEnvironmentQuery( - previewEnvironment.automationRequests({ + const initialAutomationOwner = useMemo( + () => ({ + clientId: automationClientId, environmentId: threadRef.environmentId, - input: { clientId: automationClientId }, + 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; @@ -159,13 +159,24 @@ export function PreviewAutomationOwner(props: { previewEnvironment.clearAutomationOwner, "preview automation owner clear", ); - const ownerStateRef = useRef({ threadRef, visible }); const connectedGenerationRef = useRef(null); - const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise>( - async () => undefined, - ); + 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( @@ -208,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, @@ -223,46 +234,46 @@ 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."); + if (!tabId) throw previewTabNotFoundError(); const startedAt = await startBrowserRecording(tabId); return { tabId, @@ -271,7 +282,7 @@ export function PreviewAutomationOwner(props: { }; } 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; @@ -280,34 +291,34 @@ export function PreviewAutomationOwner(props: { }, [open, threadRef, visible], ); + const [requestHandler] = useState(() => + createLatestPreviewAutomationRequestHandler(handleRequest), + ); useEffect(() => { - handlerRef.current = handleRequest; - }, [handleRequest]); + requestHandler.set(handleRequest); + }, [handleRequest, requestHandler]); - useEffect(() => { - const request = automationRequests.data; - if (!request) return; - void handlerRef.current(request).then( - (result) => - respondToAutomation({ - environmentId: threadRef.environmentId, - input: { - requestId: request.requestId, - ok: true, - ...(result === undefined ? {} : { result }), - }, - }), - (error) => - respondToAutomation({ - environmentId: threadRef.environmentId, - input: { - requestId: request.requestId, - ok: false, - error: serializeError(error), - }, - }), - ); - }, [automationRequests.data, respondToAutomation, threadRef.environmentId]); + 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 observation = observeAutomationOwnerConnectedGeneration( @@ -317,39 +328,11 @@ export function PreviewAutomationOwner(props: { connectedGenerationRef.current = observation.nextGeneration; if (!observation.shouldReport) return; - const ownerState = ownerStateRef.current; - const state = readThreadPreviewState(ownerState.threadRef); - void reportAutomationOwner({ - environmentId: ownerState.threadRef.environmentId, - input: { - 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(), - }, - }); - }, [automationClientId, connectedGeneration, reportAutomationOwner]); + void reportCurrentAutomationOwner(); + }, [connectedGeneration]); useEffect(() => { - const report = () => { - const state = readThreadPreviewState(threadRef); - void 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(), - }, - }); - }; - report(); + const report = () => void reportCurrentAutomationOwner(); window.addEventListener("focus", report); const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { if (state.snapshot?.tabId !== previous.snapshot?.tabId) { @@ -361,10 +344,14 @@ export function PreviewAutomationOwner(props: { unsubscribe(); void clearAutomationOwner({ environmentId: threadRef.environmentId, - input: { clientId: automationClientId }, + input: { + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }, }); }; - }, [automationClientId, clearAutomationOwner, reportAutomationOwner, threadRef, visible]); + }, [automationClientId, clearAutomationOwner, threadRef]); return null; } 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/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 1c923205710..800fc5efac1 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -37,6 +37,10 @@ export function createPreviewEnvironmentAtoms( automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:preview:automation-requests", tag: WS_METHODS.previewAutomationConnect, + // Automation requests are commands, not cached query data. Dispose the + // stream immediately with its owner so stale requests cannot replay when + // a thread remounts and the server can clear disconnected hosts promptly. + idleTtlMs: 0, }), open: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:open", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f9377d6bf8b..9d6ed04c286 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -75,6 +75,7 @@ import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationOwner, + PreviewAutomationOwnerIdentity, PreviewAutomationPressInput, PreviewAutomationRequest, PreviewAutomationResponse, @@ -1165,7 +1166,7 @@ export interface EnvironmentApi { ) => () => void; respond: (response: PreviewAutomationResponse) => Promise; reportOwner: (owner: PreviewAutomationOwner) => Promise; - clearOwner: (input: { clientId: string }) => Promise; + clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise; }; onEvent: ( callback: (event: PreviewEvent) => void, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 791591a7a9b..110fc2415ad 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -410,10 +410,15 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; -export const PreviewAutomationOwner = Schema.Struct({ +export const PreviewAutomationOwnerIdentity = Schema.Struct({ clientId: TrimmedNonEmptyString, environmentId: EnvironmentId, threadId: ThreadId, +}); +export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type; + +export const PreviewAutomationOwner = Schema.Struct({ + ...PreviewAutomationOwnerIdentity.fields, tabId: Schema.NullOr(PreviewTabId), visible: Schema.Boolean, supportsAutomation: Schema.Boolean, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 87c5a49c73b..a2a8e9106aa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -108,6 +108,7 @@ import { import { PreviewAutomationError, PreviewAutomationOwner, + PreviewAutomationOwnerIdentity, PreviewAutomationRequest, PreviewAutomationResponse, } from "./previewAutomation.ts"; @@ -549,7 +550,7 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, }); export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { - payload: Schema.Struct({ clientId: Schema.String }), + payload: PreviewAutomationOwner, success: PreviewAutomationRequest, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, @@ -566,7 +567,7 @@ export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAuto }); export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { - payload: Schema.Struct({ clientId: Schema.String }), + payload: PreviewAutomationOwnerIdentity, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bd9272b6c4..192723b7663 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,10 +194,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 3.4.8-snapshot.v20260619001138 - version: 3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -206,10 +206,10 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -221,7 +221,7 @@ importers: version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -242,7 +242,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -263,40 +263,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.15 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -305,19 +305,19 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-network: specifier: ~56.0.5 version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + version: 56.2.8(c021de11d02907bd585610408f5252e8) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -326,16 +326,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.15 - version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -347,43 +347,43 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: ^0.35.4 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 - version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -392,7 +392,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -10839,10 +10839,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.0': dependencies: @@ -10946,23 +10946,23 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -11529,7 +11529,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -11539,7 +11539,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -11564,7 +11564,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11590,8 +11590,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -11653,18 +11653,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -11725,13 +11725,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -11761,7 +11761,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -11779,14 +11779,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -11861,14 +11861,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -11883,18 +11883,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': + '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12155,13 +12155,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -12984,15 +12984,15 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13052,7 +13052,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13062,7 +13062,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.1 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color @@ -13110,7 +13110,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13118,18 +13118,16 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13542,15 +13540,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': dependencies: - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.8) - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14626,8 +14624,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15518,29 +15516,29 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -15548,119 +15546,119 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15673,66 +15671,66 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-network@56.0.5(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): + expo-router@56.2.8(c021de11d02907bd585610408f5252e8): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -15740,18 +15738,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -15763,7 +15761,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -15771,7 +15769,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -15779,20 +15777,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -15800,7 +15798,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -15810,25 +15808,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): + expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) - expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -15837,37 +15835,37 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d): + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -18285,102 +18283,102 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): + react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.1 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18392,23 +18390,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.1 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -19437,14 +19435,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} From ab0baaa88614905722a6638aa56ec858caf9c563 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:34:44 -0700 Subject: [PATCH 023/142] [codex] Inline contracts request-context service shapes (#3204) Co-authored-by: codex --- packages/contracts/src/relay.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8b0068e730d..dea3709f488 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -1,5 +1,5 @@ -import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; @@ -512,26 +512,22 @@ const RelayAgentActivityPublishErrors = [ RelayInternalError, ] as const; -export interface RelayClientPrincipalShape { - readonly userId: string; - readonly token: string; - readonly proofKeyThumbprint?: string; - readonly dpopScopes?: ReadonlyArray; -} - export class RelayClientPrincipal extends Context.Service< RelayClientPrincipal, - RelayClientPrincipalShape + { + readonly userId: string; + readonly token: string; + readonly proofKeyThumbprint?: string; + readonly dpopScopes?: ReadonlyArray; + } >()("@t3tools/contracts/relay/RelayClientPrincipal") {} -export interface RelayEnvironmentPrincipalShape { - readonly environmentId: string; - readonly environmentPublicKey: string; -} - export class RelayEnvironmentPrincipal extends Context.Service< RelayEnvironmentPrincipal, - RelayEnvironmentPrincipalShape + { + readonly environmentId: string; + readonly environmentPublicKey: string; + } >()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( From 60b16d1f28104c95a3ab8e673cb439124a4a8668 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:42:41 -0700 Subject: [PATCH 024/142] [codex] Refactor desktop app Effect services (#3185) Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 4 +- apps/desktop/src/app/DesktopAppIdentity.ts | 10 +- apps/desktop/src/app/DesktopAssets.ts | 15 +- .../src/app/DesktopBackendOutputLog.ts | 280 +++++++++++++++++ apps/desktop/src/app/DesktopClerk.test.ts | 17 +- apps/desktop/src/app/DesktopClerk.ts | 21 +- apps/desktop/src/app/DesktopEnvironment.ts | 105 +++---- apps/desktop/src/app/DesktopLifecycle.ts | 209 ++++++------- apps/desktop/src/app/DesktopObservability.ts | 284 +----------------- apps/desktop/src/app/DesktopShutdown.ts | 35 +++ apps/desktop/src/app/DesktopState.ts | 26 +- .../src/backend/DesktopBackendManager.test.ts | 10 +- apps/desktop/src/main.ts | 5 +- apps/desktop/src/preview/Manager.test.ts | 4 +- .../src/shell/DesktopShellEnvironment.test.ts | 2 +- apps/desktop/src/updates/DesktopUpdates.ts | 2 +- apps/desktop/src/window/DesktopWindow.test.ts | 12 +- 17 files changed, 521 insertions(+), 520 deletions(-) create mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.ts create mode 100644 apps/desktop/src/app/DesktopShutdown.ts diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index eafdbf056dc..7c4c06eb616 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -63,7 +63,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 +73,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; diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 52f4b12808e..2664581b187 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,14 +18,12 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export interface DesktopAppIdentityShape { - readonly resolveUserDataPath: Effect.Effect; - readonly configure: Effect.Effect; -} - 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 => { diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 3b5a15e435f..7591d6fd295 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -12,14 +12,13 @@ export interface DesktopIconPaths { readonly png: Option.Option; } -export interface DesktopAssetsShape { - readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: (fileName: string) => Effect.Effect>; -} - -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>; + } +>()("@t3tools/desktop/app/DesktopAssets") {} const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( fileName: string, 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 index 84eab6598a9..a80a9fe24fb 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -21,10 +21,6 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); -import { - createDesktopClerkBridge, - resolveDesktopClerkFrontendApiHostname, -} from "./DesktopClerk.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -32,9 +28,12 @@ describe("DesktopClerk", () => { it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - assert.equal(resolveDesktopClerkFrontendApiHostname(publishableKey), "clerk.t3.codes"); - assert.equal(resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(resolveDesktopClerkFrontendApiHostname("invalid"), undefined); + 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", () => { @@ -44,7 +43,7 @@ describe("DesktopClerk", () => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment: true, - } as unknown as DesktopEnvironment.DesktopEnvironmentShape); + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return Effect.gen(function* () { yield* Effect.scoped( @@ -78,7 +77,7 @@ describe("DesktopClerk", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); - assert.equal(createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); + assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 5fa8e0ffbca..1fa5640b2ee 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -14,17 +14,16 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; -export interface DesktopClerkShape { - readonly configure: Effect.Effect< - void, - never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope - >; -} - -export class DesktopClerk extends Context.Service()( - "@t3tools/desktop/app/DesktopClerk", -) {} +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, 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..89a9389c93f 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,7 +1,6 @@ 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 Scope from "effect/Scope"; @@ -10,63 +9,34 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopShutdownModule 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 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 { DesktopShutdown, layer as layerShutdown } from "./DesktopShutdown.ts"; export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment - | DesktopShutdown + | DesktopShutdownModule.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"); @@ -93,8 +63,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* DesktopShutdownModule.DesktopShutdown; yield* shutdown.request; yield* shutdown.awaitComplete; }, @@ -154,83 +124,82 @@ 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; - }, - ); +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) => + 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; + }, + ); + }); + 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..eae352aa376 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,160 +50,6 @@ 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, @@ -260,90 +74,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 +86,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 +105,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 +117,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/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/main.ts b/apps/desktop/src/main.ts index 326fc1af0ca..96461ab841a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,7 +14,6 @@ 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"; @@ -68,8 +67,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) { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 687cdb75637..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* () { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 897e7336a24..195902c3c92 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -64,7 +64,7 @@ function runShellEnvironment(input: { DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ platform: input.platform, - } as DesktopEnvironment.DesktopEnvironmentShape), + } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e6c81d8d25b..8a232788590 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -127,7 +127,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect(), }), 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)({ From 53d0107759b743cdd7a9b6e777d4628650ed2131 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:56:36 -0700 Subject: [PATCH 025/142] [codex] align relay foundation Effect services (#3182) Co-authored-by: codex --- infra/relay/alchemy.run.ts | 6 +- infra/relay/src/Config.ts | 33 +++-- .../AgentActivityPublisher.test.ts | 4 +- .../src/agentActivity/AgentActivityRows.ts | 4 +- infra/relay/src/agentActivity/ApnsClient.ts | 12 +- .../src/agentActivity/ApnsDeliveries.test.ts | 4 +- .../agentActivity/DeliveryAttempts.test.ts | 26 ++-- .../src/agentActivity/DeliveryAttempts.ts | 4 +- infra/relay/src/agentActivity/Devices.test.ts | 20 ++- infra/relay/src/agentActivity/Devices.ts | 4 +- .../src/agentActivity/LiveActivities.test.ts | 14 +- .../relay/src/agentActivity/LiveActivities.ts | 4 +- .../agentActivity/MobileRegistrations.test.ts | 6 +- infra/relay/src/auth/DpopProofs.test.ts | 14 +- infra/relay/src/auth/DpopProofs.ts | 53 ++++--- .../auth/DpopProofs.verifyAndConsume.test.ts | 6 +- infra/relay/src/auth/RelayTokens.test.ts | 4 +- infra/relay/src/auth/RelayTokens.ts | 73 +++++----- infra/relay/src/db.ts | 11 +- .../environments/EnvironmentConnector.test.ts | 10 +- .../src/environments/EnvironmentConnector.ts | 30 ++-- .../EnvironmentCredentials.test.ts | 10 +- .../environments/EnvironmentCredentials.ts | 38 +++-- .../environments/EnvironmentLinker.test.ts | 6 +- .../src/environments/EnvironmentLinker.ts | 39 +++--- .../src/environments/EnvironmentLinks.test.ts | 24 ++-- .../src/environments/EnvironmentLinks.ts | 81 ++++++----- .../EnvironmentPublishSignatures.test.ts | 4 +- .../EnvironmentPublishSignatures.ts | 18 ++- .../ManagedEndpointAllocations.ts | 54 ++++--- .../ManagedEndpointProvider.test.ts | 6 +- .../environments/ManagedEndpointProvider.ts | 132 +++++++++--------- infra/relay/src/http/Api.test.ts | 4 +- infra/relay/src/http/Api.ts | 13 +- infra/relay/src/observability.test.ts | 4 +- infra/relay/src/worker.ts | 13 +- 36 files changed, 403 insertions(+), 385 deletions(-) diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index b9e35fd132d..c4ebb2d80e4 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -7,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Planetscale from "alchemy/Planetscale"; -import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; +import * as RelayDb from "./src/db.ts"; import { RelayObservability } from "./src/observability.ts"; import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; import Api from "./src/worker.ts"; @@ -24,8 +24,8 @@ export default Alchemy.Stack( state: Cloudflare.state(), }, Effect.gen(function* () { - const db = yield* PlanetscaleDatabase; - const hyperdrive = yield* RelayHyperdrive; + const db = yield* RelayDb.PlanetscaleDatabase; + const hyperdrive = yield* RelayDb.RelayHyperdrive; const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); const observability = yield* RelayObservability; diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 23f3ba061b1..e7c7d42f2ae 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; @@ -13,20 +14,24 @@ export interface ApnsCredentials { readonly environment: ApnsEnvironment; } -export interface RelayConfigurationShape { - readonly relayIssuer: string; - readonly apns: ApnsCredentials; - readonly clerkSecretKey: Redacted.Redacted; - readonly clerkPublishableKey: string; - readonly clerkJwtAudience: string; - readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; - readonly cloudMintPrivateKey: Redacted.Redacted; - readonly cloudMintPublicKey: string; - readonly managedEndpointBaseDomain: string | undefined; - readonly managedEndpointNamespace: string | undefined; -} - export class RelayConfiguration extends Context.Service< RelayConfiguration, - RelayConfigurationShape + { + readonly relayIssuer: string; + readonly apns: ApnsCredentials; + readonly clerkSecretKey: Redacted.Redacted; + readonly clerkPublishableKey: string; + readonly clerkJwtAudience: string; + readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; + readonly cloudMintPrivateKey: Redacted.Redacted; + readonly cloudMintPublicKey: string; + readonly managedEndpointBaseDomain: string | undefined; + readonly managedEndpointNamespace: string | undefined; + } >()("t3code-relay/Config/RelayConfiguration") {} + +export const make = (configuration: RelayConfiguration["Service"]) => + RelayConfiguration.of(configuration); + +export const layer = (configuration: RelayConfiguration["Service"]) => + Layer.succeed(RelayConfiguration, make(configuration)); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 5f27c2f1821..322ac77d896 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -66,8 +66,8 @@ function makeAgentActivityRows( } function makeEnvironmentLinks( - overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 6f940c5523f..a0695b8e7da 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { and, desc, eq, isNull } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; export class AgentActivityRowUpsertPersistenceError extends Schema.TaggedErrorClass()( @@ -70,7 +70,7 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( ); const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ upsert: Effect.fn("relay.agent_activity_rows.upsert")( diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index a779085118d..90c0fe7dc84 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; -import type { ApnsCredentials } from "../Config.ts"; +import * as RelayConfiguration from "../Config.ts"; import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; @@ -99,9 +99,9 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( ); const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { - readonly teamId: ApnsCredentials["teamId"]; - readonly keyId: ApnsCredentials["keyId"]; - readonly privateKey: ApnsCredentials["privateKey"]; + readonly teamId: RelayConfiguration.ApnsCredentials["teamId"]; + readonly keyId: RelayConfiguration.ApnsCredentials["keyId"]; + readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"]; readonly issuedAtUnixSeconds: number; }) { const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( @@ -235,12 +235,12 @@ export interface ApnsClientShape { readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; readonly makePushNotificationRequest: typeof makePushNotificationRequest; readonly sendLiveActivityRequest: (input: { - readonly credentials: ApnsCredentials; + readonly credentials: RelayConfiguration.ApnsCredentials; readonly request: ApnsLiveActivityRequest; readonly issuedAtUnixSeconds: number; }) => Effect.Effect; readonly sendPushNotificationRequest: (input: { - readonly credentials: ApnsCredentials; + readonly credentials: RelayConfiguration.ApnsCredentials; readonly request: ApnsPushNotificationRequest; readonly issuedAtUnixSeconds: number; }) => Effect.Effect; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 0dfee1fb0cd..81de6d32687 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -153,7 +153,7 @@ function makeLayer(input: { Parameters[0] >; readonly currentTargets?: ReadonlyArray; - readonly config?: RelayConfiguration.RelayConfigurationShape; + readonly config?: RelayConfiguration.RelayConfiguration["Service"]; readonly execute?: ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; @@ -213,7 +213,7 @@ function makeLayer(input: { input.invalidatedTokens?.push(invalidated); }), }), - Layer.succeed(RelayConfiguration.RelayConfiguration, input.config ?? config), + RelayConfiguration.layer(input.config ?? config), input.execute ? Layer.succeed(HttpClient.HttpClient, HttpClient.make(input.execute)) : FetchHttpClient.layer, diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts index 81abb330726..8231fe17afa 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDeliveryAttempts } from "../persistence/schema.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; @@ -20,7 +20,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -52,7 +52,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -78,7 +78,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -104,7 +104,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -135,7 +135,7 @@ describe("DeliveryAttempts", () => { }), }), }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -154,7 +154,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -185,7 +185,7 @@ describe("DeliveryAttempts", () => { }), }), }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -204,7 +204,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -246,7 +246,7 @@ describe("DeliveryAttempts", () => { }; }, }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -266,7 +266,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -290,7 +290,7 @@ describe("DeliveryAttempts", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -315,7 +315,7 @@ describe("DeliveryAttempts", () => { Effect.provide( DeliveryAttempts.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index b88e5c82c51..6eb9b93c388 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -7,7 +7,7 @@ import { and, eq, isNull } from "drizzle-orm"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Schema.TaggedErrorClass()( @@ -84,7 +84,7 @@ function insertValues( } const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; const isExpiredClaim = (claimedAt: string | null, now: DateTime.DateTime) => { diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 7a3b227703f..bcc627d8f90 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -5,7 +5,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; import * as Devices from "./Devices.ts"; @@ -71,7 +71,7 @@ describe("Devices", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -110,7 +110,9 @@ describe("Devices", () => { pushToStartToken: "push-to-start-token", }), ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("unregisters APNs state only for the current user device", () => { @@ -130,7 +132,7 @@ describe("Devices", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -156,7 +158,9 @@ describe("Devices", () => { params: ["user-2", "device-1"], }, ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("lists safe notification state without exposing APNs tokens", () => { @@ -184,7 +188,7 @@ describe("Devices", () => { }; }, }), - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -215,6 +219,8 @@ describe("Devices", () => { updatedAt: "2026-06-01T00:00:00.000Z", }, ]); - }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); }); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86c338b0912..108735f27ae 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -10,7 +10,7 @@ import * as Schema from "effect/Schema"; import { and, eq } from "drizzle-orm"; import { sql } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass()( @@ -59,7 +59,7 @@ export class Devices extends Context.Service()( ) {} const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return Devices.of({ register: Effect.fn("relay.devices.register")( diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 19a1179b305..8c6455c8622 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -8,7 +8,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities } from "../persistence/schema.ts"; import * as LiveActivities from "./LiveActivities.ts"; @@ -88,7 +88,7 @@ describe("LiveActivities", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; @@ -138,7 +138,9 @@ describe("LiveActivities", () => { }), ); }).pipe( - Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), ); }, ); @@ -164,7 +166,7 @@ describe("LiveActivities", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; @@ -190,7 +192,9 @@ describe("LiveActivities", () => { }), ); }).pipe( - Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), ); }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index e7649922124..988dd6988b2 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -12,7 +12,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { and, eq, sql } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class LiveActivityRegistrationPersistenceError extends Schema.TaggedErrorClass()( @@ -108,7 +108,7 @@ const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( ); const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return LiveActivities.of({ register: Effect.fn("relay.live_activities.register")( diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 74cf905523b..8d8e6f21461 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -86,8 +86,8 @@ function makeAgentActivityRows( } function makeEnvironmentLinks( - overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), @@ -153,7 +153,7 @@ function makeRegistrationReplayLayer(input: { Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), Layer.succeed(DeliveryAttempts.DeliveryAttempts, makeDeliveryAttempts()), - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { send: (body) => Effect.sync(() => { diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts index 9fae6298c9c..b294ba396b6 100644 --- a/infra/relay/src/auth/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; import * as DpopProofs from "./DpopProofs.ts"; @@ -41,7 +41,7 @@ describe("DpopProofReplay", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; @@ -67,7 +67,9 @@ describe("DpopProofReplay", () => { expiresAt: "2026-05-25T12:00:00.000Z", }, ]); - }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); it.effect("prunes expired proof rows from the maintenance path", () => { @@ -84,12 +86,14 @@ describe("DpopProofReplay", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const replay = yield* DpopProofs.DpopProofReplay; yield* replay.pruneExpired; expect(calls).toEqual(["delete", "delete.where"]); - }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))), + ); }); }); diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts index cd59a984fa1..cf3f7a4cf5a 100644 --- a/infra/relay/src/auth/DpopProofs.ts +++ b/infra/relay/src/auth/DpopProofs.ts @@ -7,7 +7,7 @@ import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; import { lt } from "drizzle-orm"; import { verifyDpopProof } from "@t3tools/shared/dpop"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass()( @@ -21,34 +21,31 @@ export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass Effect.Effect; - - readonly consume: (input: { - readonly thumbprint: string; - readonly jti: string; - readonly iat: number; - readonly expiresAt: DateTime.DateTime; - }) => Effect.Effect; - - readonly pruneExpired: Effect.Effect; -} - -export class DpopProofReplay extends Context.Service()( - "t3code-relay/auth/DpopProofs/DpopProofReplay", -) {} +export class DpopProofReplay extends Context.Service< + DpopProofReplay, + { + readonly verifyAndConsume: (input: { + readonly proof: string | undefined; + readonly method: string; + readonly url: string; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; + readonly now: DateTime.DateTime; + }) => Effect.Effect; + readonly consume: (input: { + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + readonly expiresAt: DateTime.DateTime; + }) => Effect.Effect; + readonly pruneExpired: Effect.Effect; + } +>()("t3code-relay/auth/DpopProofs/DpopProofReplay") {} const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; - const consume: DpopProofReplayShape["consume"] = Effect.fn("relay.dpop_proofs.consume")( + const consume: DpopProofReplay["Service"]["consume"] = Effect.fn("relay.dpop_proofs.consume")( function* (input) { const createdAt = DateTime.formatIso(yield* DateTime.now); const inserted = yield* db @@ -67,7 +64,7 @@ const make = Effect.gen(function* () { Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), ); - const verifyAndConsume: DpopProofReplayShape["verifyAndConsume"] = Effect.fn( + const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn( "relay.dpop_proofs.verify_and_consume", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -114,7 +111,7 @@ const make = Effect.gen(function* () { return result.thumbprint; }); - const pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.gen(function* () { + const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index d09ee76e42c..ecb33f1fc06 100644 --- a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -10,7 +10,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayDpopProofs } from "../persistence/schema.ts"; import * as DpopProofs from "./DpopProofs.ts"; @@ -78,8 +78,8 @@ function layer( }), }; }, - } as unknown as RelayDatabase; - return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))); + } as unknown as RelayDb.RelayDb["Service"]; + return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))); } function consumeEachProofOnce() { diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index d4ca885b86c..c4a65771e58 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -33,9 +33,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointNamespace: undefined, }); -const layer = RelayTokens.layer.pipe( - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), -); +const layer = RelayTokens.layer.pipe(Layer.provide(RelayConfiguration.layer(config))); describe("RelayTokens", () => { it.effect("issues a user-bound environment link challenge", () => diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index db0a9499e0a..6c726ffa826 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -93,45 +93,44 @@ function resolveDpopAccessTokenScopes(input: { }); } -export interface RelayTokensShape { - readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; - readonly issueLinkChallenge: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; - }) => Effect.Effect; - readonly verifyLinkChallenge: (input: { - readonly token: string; - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly nowEpochSeconds: number; - }) => Effect.Effect; - readonly issueDpopAccessToken: (input: { - readonly userId: string; - readonly proofKeyThumbprint: string; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; - readonly clientId: RelayPublicClientId; - readonly scopes: ReadonlyArray; - }) => Effect.Effect; - readonly verifyDpopAccessToken: (input: { - readonly token: string; - readonly nowEpochSeconds: number; - }) => Effect.Effect; -} - -export class RelayTokens extends Context.Service()( - "t3code-relay/auth/RelayTokens", -) {} +export class RelayTokens extends Context.Service< + RelayTokens, + { + readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; + readonly issueLinkChallenge: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + }) => Effect.Effect; + readonly verifyLinkChallenge: (input: { + readonly token: string; + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly nowEpochSeconds: number; + }) => Effect.Effect; + readonly issueDpopAccessToken: (input: { + readonly userId: string; + readonly proofKeyThumbprint: string; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly clientId: RelayPublicClientId; + readonly scopes: ReadonlyArray; + }) => Effect.Effect; + readonly verifyDpopAccessToken: (input: { + readonly token: string; + readonly nowEpochSeconds: number; + }) => Effect.Effect; + } +>()("t3code-relay/auth/RelayTokens") {} const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const issuer = normalizeRelayIssuer(config.relayIssuer); - const issueLinkChallenge: RelayTokensShape["issueLinkChallenge"] = Effect.fn( + const issueLinkChallenge: RelayTokens["Service"]["issueLinkChallenge"] = Effect.fn( "relay.tokens.issue_link_challenge", )(function* (input) { return yield* signRelayJwt({ @@ -150,7 +149,7 @@ const make = Effect.gen(function* () { }); }); - const verifyLinkChallenge: RelayTokensShape["verifyLinkChallenge"] = Effect.fn( + const verifyLinkChallenge: RelayTokens["Service"]["verifyLinkChallenge"] = Effect.fn( "relay.tokens.verify_link_challenge", )((input) => verifyRelayJwt({ @@ -177,7 +176,7 @@ const make = Effect.gen(function* () { ), ); - const issueDpopAccessToken: RelayTokensShape["issueDpopAccessToken"] = Effect.fn( + const issueDpopAccessToken: RelayTokens["Service"]["issueDpopAccessToken"] = Effect.fn( "relay.tokens.issue_dpop_access_token", )(function* (input) { return yield* signRelayJwt({ @@ -197,7 +196,7 @@ const make = Effect.gen(function* () { }); }); - const verifyDpopAccessToken: RelayTokensShape["verifyDpopAccessToken"] = Effect.fn( + const verifyDpopAccessToken: RelayTokens["Service"]["verifyDpopAccessToken"] = Effect.fn( "relay.tokens.verify_dpop_access_token", )((input) => verifyRelayJwt({ diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index e812fc7b686..99db09439c3 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -10,11 +10,12 @@ import * as Effect from "effect/Effect"; import { relayDatabaseMode } from "./dbConfig.ts"; -export interface RelayDatabase extends EffectPgDatabase { - readonly $client: PgClient; -} - -export class RelayDb extends Context.Service()("t3code-relay/db/RelayDb") {} +export class RelayDb extends Context.Service< + RelayDb, + EffectPgDatabase & { + readonly $client: PgClient; + } +>()("t3code-relay/db/RelayDb") {} export const PlanetscaleDatabase = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index c3b86e7ba4c..63f12379870 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -161,8 +161,8 @@ function connectorTestLayer( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect, options?: { - readonly links?: EnvironmentLinks.EnvironmentLinksShape; - readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocationsShape; + readonly links?: EnvironmentLinks.EnvironmentLinks["Service"]; + readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocations["Service"]; }, ) { return EnvironmentConnector.layer.pipe( @@ -174,7 +174,7 @@ function connectorTestLayer( options?.allocations ?? makeAllocations(), ), ), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(RelayConfiguration.layer(settings)), Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), ); } @@ -189,7 +189,7 @@ function makeAllocations( dnsRecordId: "dns-record-id", readyAt: "2026-05-25T00:00:00.000Z", }, -): ManagedEndpointAllocations.ManagedEndpointAllocationsShape { +): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] { return { get: () => Effect.succeed(allocation), reserve: () => Effect.die("unused"), @@ -202,7 +202,7 @@ function makeAllocations( function makeLinks( overrides: Partial = {}, -): EnvironmentLinks.EnvironmentLinksShape { +): EnvironmentLinks.EnvironmentLinks["Service"] { return { upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed([]), diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 784fb535344..db662aee94d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -35,7 +35,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; @@ -139,22 +141,20 @@ export type EnvironmentConnectorError = export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000; const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000; -export interface EnvironmentConnectorShape { - readonly connect: (input: { - readonly userId: string; - readonly environmentId: string; - readonly clientProofKeyThumbprint: string; - readonly deviceId?: string; - }) => Effect.Effect; - readonly status: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - export class EnvironmentConnector extends Context.Service< EnvironmentConnector, - EnvironmentConnectorShape + { + readonly connect: (input: { + readonly userId: string; + readonly environmentId: string; + readonly clientProofKeyThumbprint: string; + readonly deviceId?: string; + }) => Effect.Effect; + readonly status: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentConnector") {} const decodeMintResponseProof = Schema.decodeUnknownEffect( diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 9282564e985..733658cbb5e 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -4,7 +4,7 @@ import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentCredentials } from "../persistence/schema.ts"; import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; @@ -47,7 +47,7 @@ describe("EnvironmentCredentials", () => { }), }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; @@ -87,7 +87,7 @@ describe("EnvironmentCredentials", () => { Effect.provide( EnvironmentCredentials.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); @@ -118,7 +118,7 @@ describe("EnvironmentCredentials", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; @@ -150,7 +150,7 @@ describe("EnvironmentCredentials", () => { Effect.provide( EnvironmentCredentials.layer.pipe( Layer.provide(NodeCryptoLayer.layer), - Layer.provide(Layer.succeed(RelayDb, fakeDb)), + Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)), ), ), ); diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index 13ced74c77a..e318ce1e098 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { and, eq, isNull, ne, notExists } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass()( @@ -44,30 +44,28 @@ export interface EnvironmentCredentialPrincipal { readonly environmentPublicKey: string; } -export interface EnvironmentCredentialsShape { - readonly create: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect; - readonly authenticate: ( - token: string, - ) => Effect.Effect< - Option.Option, - EnvironmentCredentialAuthenticatePersistenceError - >; - readonly revokeForEnvironmentPublicKey: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect; -} - export class EnvironmentCredentials extends Context.Service< EnvironmentCredentials, - EnvironmentCredentialsShape + { + readonly create: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; + readonly authenticate: ( + token: string, + ) => Effect.Effect< + Option.Option, + EnvironmentCredentialAuthenticatePersistenceError + >; + readonly revokeForEnvironmentPublicKey: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentCredentials") {} const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; const hashToken = (token: string) => crypto diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index dce364bffac..35dbd907dbe 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -105,14 +105,14 @@ const makeRequest = Effect.gen(function* () { }); function testLayer(input?: { - readonly upsert?: EnvironmentLinks.EnvironmentLinksShape["upsert"]; - readonly consume?: DpopProofs.DpopProofReplayShape["consume"]; + readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"]; + readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"]; }) { return EnvironmentLinker.layer.pipe( Layer.provideMerge(RelayTokens.layer), Layer.provide( Layer.mergeAll( - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(DpopProofs.DpopProofReplay, { verifyAndConsume: () => Effect.die("unexpected DPoP proof verification"), consume: input?.consume ?? (() => Effect.succeed(true)), diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 5eb12181692..9cb422bd317 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -53,26 +53,25 @@ export type EnvironmentLinkError = | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError | ManagedEndpointProvider.ManagedEndpointProviderError; -export interface EnvironmentLinkerShape { - readonly link: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkRequest; - }) => Effect.Effect< - { - readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; - readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; - readonly endpointRuntime: - | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] - | null; - readonly environmentCredential: string; - }, - EnvironmentLinkError - >; -} - -export class EnvironmentLinker extends Context.Service()( - "t3code-relay/environments/EnvironmentLinker", -) {} +export class EnvironmentLinker extends Context.Service< + EnvironmentLinker, + { + readonly link: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + }) => Effect.Effect< + { + readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; + readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; + readonly endpointRuntime: + | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] + | null; + readonly environmentCredential: string; + }, + EnvironmentLinkError + >; + } +>()("t3code-relay/environments/EnvironmentLinker") {} const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index b67dfb8e430..346daef44a6 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -3,9 +3,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { PgDialect } from "drizzle-orm/pg-core"; -import { RelayDb, type RelayDatabase } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; -import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { it.effect("selects users when either notifications or Live Activities are enabled", () => { @@ -25,10 +25,10 @@ describe("EnvironmentLinks", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { - const links = yield* EnvironmentLinks; + const links = yield* EnvironmentLinks.EnvironmentLinks; expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]); expect(whereConditions).toHaveLength(1); @@ -39,7 +39,11 @@ describe("EnvironmentLinks", () => { expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3'); expect(query.sql).toContain(" or "); expect(query.params).toEqual(["env-1", true, true]); - }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); }); it.effect("revokes only the active link owned by the requesting user", () => { @@ -65,10 +69,10 @@ describe("EnvironmentLinks", () => { }, }; }, - } as unknown as RelayDatabase; + } as unknown as RelayDb.RelayDb["Service"]; return Effect.gen(function* () { - const links = yield* EnvironmentLinks; + const links = yield* EnvironmentLinks.EnvironmentLinks; const revoked = yield* links.revokeForUser({ userId: "user-1", environmentId: "env-1", @@ -86,6 +90,10 @@ describe("EnvironmentLinks", () => { expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); expect(query.params).toEqual(["user-1", "env-1"]); - }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); }); }); diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 9ed48c27905..ee7019656cc 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { and, eq, isNull, or } from "drizzle-orm"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { @@ -78,45 +78,44 @@ export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorCla } } -export interface EnvironmentLinksShape { - readonly upsert: (input: { - readonly userId: string; - readonly request: RelayEnvironmentLinkRequest; - readonly proof: RelayEnvironmentLinkProofPayload; - readonly endpoint: RelayManagedEndpoint; - }) => Effect.Effect; - readonly listUsersForEnvironment: (input: { - readonly environmentId: string; - }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>; - readonly listDeliveryUsersForEnvironment: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - }) => Effect.Effect< - ReadonlyArray, - EnvironmentLinkUserListPersistenceError - >; - readonly listPublicKeysForEnvironment: (input: { - readonly environmentId: string; - }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect< - ReadonlyArray, - EnvironmentLinkListPersistenceError - >; - readonly getForUser: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; - readonly revokeForUser: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - -export class EnvironmentLinks extends Context.Service()( - "t3code-relay/environments/EnvironmentLinks", -) {} +export class EnvironmentLinks extends Context.Service< + EnvironmentLinks, + { + readonly upsert: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + readonly proof: RelayEnvironmentLinkProofPayload; + readonly endpoint: RelayManagedEndpoint; + }) => Effect.Effect; + readonly listUsersForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>; + readonly listDeliveryUsersForEnvironment: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkUserListPersistenceError + >; + readonly listPublicKeysForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkListPersistenceError + >; + readonly getForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + readonly revokeForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } +>()("t3code-relay/environments/EnvironmentLinks") {} function agentAwarenessDeliveryUserCondition(environmentId: string) { return and( @@ -140,7 +139,7 @@ function agentAwarenessDeliveryUserKeyCondition(input: { } const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return EnvironmentLinks.of({ upsert: Effect.fn("relay.environment_links.upsert")( diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index a74ce670cfb..2b19d4c9f1f 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -80,11 +80,11 @@ const freshRequest = Effect.gen(function* () { } satisfies RelayAgentActivityPublishRequest; }); -function layer(replay?: Partial) { +function layer(replay?: Partial) { return EnvironmentPublishSignatures.layer.pipe( Layer.provide( Layer.merge( - Layer.succeed(RelayConfiguration.RelayConfiguration, config), + RelayConfiguration.layer(config), Layer.succeed(DpopProofs.DpopProofReplay, { verifyAndConsume: replay?.verifyAndConsume ?? (() => Effect.die("unexpected DPoP proof verification")), diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 4d2d316b228..ffc8c124b7b 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -59,18 +59,16 @@ export type EnvironmentPublishSignatureError = | EnvironmentPublishPublicKeyMissing | DpopProofs.DpopProofReplayPersistenceError; -export interface EnvironmentPublishSignaturesShape { - readonly verify: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - readonly request: RelayAgentActivityPublishRequest; - }) => Effect.Effect; -} - export class EnvironmentPublishSignatures extends Context.Service< EnvironmentPublishSignatures, - EnvironmentPublishSignaturesShape + { + readonly verify: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly request: RelayAgentActivityPublishRequest; + }) => Effect.Effect; + } >()("t3code-relay/environments/EnvironmentPublishSignatures") {} const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 7809b43393e..440f1d48dc3 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts"; import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; @@ -66,26 +66,29 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { readonly dnsRecordId: string; } -export interface ManagedEndpointAllocationsShape { - readonly get: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; - readonly reserve: ( - input: ReserveManagedEndpointAllocationInput, - ) => Effect.Effect; - readonly recordTunnel: ( - input: RecordManagedEndpointTunnelInput, - ) => Effect.Effect; - readonly recordDns: ( - input: RecordManagedEndpointDnsInput, - ) => Effect.Effect; - readonly markReady: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; - readonly remove: ( - input: ManagedEndpointAllocationKey, - ) => Effect.Effect; -} +export class ManagedEndpointAllocations extends Context.Service< + ManagedEndpointAllocations, + { + readonly get: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly reserve: ( + input: ReserveManagedEndpointAllocationInput, + ) => Effect.Effect; + readonly recordTunnel: ( + input: RecordManagedEndpointTunnelInput, + ) => Effect.Effect; + readonly recordDns: ( + input: RecordManagedEndpointDnsInput, + ) => Effect.Effect; + readonly markReady: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly remove: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + } +>()("t3code-relay/environments/ManagedEndpointAllocations") {} const allocationSelection = { userId: relayManagedEndpointAllocations.userId, @@ -109,7 +112,7 @@ const persistenceError = (cause: unknown) => : new ManagedEndpointAllocationPersistenceError({ cause }); const make = Effect.gen(function* () { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return ManagedEndpointAllocations.of({ get: Effect.fn("relay.managed_endpoint_allocations.get")(function* ( @@ -198,9 +201,4 @@ const make = Effect.gen(function* () { }); }); -export class ManagedEndpointAllocations extends Context.Service< - ManagedEndpointAllocations, - ManagedEndpointAllocationsShape ->()("t3code-relay/environments/ManagedEndpointAllocations") { - static readonly layer = Layer.effect(this, make); -} +export const layer = Layer.effect(ManagedEndpointAllocations, make); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index d9a9db9ce6b..7b8f0cc2867 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -204,9 +204,9 @@ function providerLayer( ) { return ManagedEndpointProvider.layer.pipe( Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), - Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointDnsClient, dnsClient)), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide(ManagedEndpointProvider.layerTunnelClient(tunnelClient)), + Layer.provide(ManagedEndpointProvider.layerDnsClient(dnsClient)), Layer.provide( Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations), ), diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index bdbcc569dcb..2de9d1966ac 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -23,7 +23,7 @@ import { managedEndpointHostname, managedEndpointTunnelName, } from "../deploymentConfig.ts"; -import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass()( "ManagedEndpointProvisioningNotConfigured", @@ -74,21 +74,19 @@ export interface ManagedEndpointProvisioningResult { readonly runtime: RelayManagedEndpointRuntimeConfig; } -export interface ManagedEndpointProviderShape { - readonly provision: (input: { - readonly userId: string; - readonly environmentId: string; - readonly origin: RelayManagedEndpointOrigin; - }) => Effect.Effect; - readonly deprovision: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; -} - export class ManagedEndpointProvider extends Context.Service< ManagedEndpointProvider, - ManagedEndpointProviderShape + { + readonly provision: (input: { + readonly userId: string; + readonly environmentId: string; + readonly origin: RelayManagedEndpointOrigin; + }) => Effect.Effect; + readonly deprovision: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider") {} interface ManagedEndpointTunnel { @@ -105,36 +103,42 @@ export class ManagedEndpointTunnelClientError extends Schema.TaggedErrorClass Effect.Effect< - { readonly result: ReadonlyArray }, - ManagedEndpointTunnelClientError - >; - readonly create: (request: { - readonly name: string; - readonly configSrc: "cloudflare"; - }) => Effect.Effect; - readonly putConfiguration: ( - tunnelId: string, - config: { - readonly ingress: Array<{ - readonly hostname?: string; - readonly service: string; - }>; - }, - ) => Effect.Effect; - readonly getToken: (tunnelId: string) => Effect.Effect; - readonly delete: (tunnelId: string) => Effect.Effect; -} - export class ManagedEndpointTunnelClient extends Context.Service< ManagedEndpointTunnelClient, - ManagedEndpointTunnelClientShape + { + readonly list: (request: { + readonly name: string; + readonly isDeleted: false; + }) => Effect.Effect< + { readonly result: ReadonlyArray }, + ManagedEndpointTunnelClientError + >; + readonly create: (request: { + readonly name: string; + readonly configSrc: "cloudflare"; + }) => Effect.Effect; + readonly putConfiguration: ( + tunnelId: string, + config: { + readonly ingress: Array<{ + readonly hostname?: string; + readonly service: string; + }>; + }, + ) => Effect.Effect; + readonly getToken: ( + tunnelId: string, + ) => Effect.Effect; + readonly delete: (tunnelId: string) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} +export const makeTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) => + ManagedEndpointTunnelClient.of(client); + +export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) => + Layer.succeed(ManagedEndpointTunnelClient, makeTunnelClient(client)); + interface ManagedEndpointCnameRecordInput { readonly type: "CNAME"; readonly name: string; @@ -152,29 +156,33 @@ export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass Effect.Effect, ManagedEndpointDnsClientError>; - readonly createRecord: ( - request: ManagedEndpointCnameRecordInput, - ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; - readonly updateRecord: ( - dnsRecordId: string, - request: ManagedEndpointCnameRecordInput, - ) => Effect.Effect; - readonly deleteRecord: ( - dnsRecordId: string, - ) => Effect.Effect; -} - export class ManagedEndpointDnsClient extends Context.Service< ManagedEndpointDnsClient, - ManagedEndpointDnsClientShape + { + readonly listRecords: ( + hostname: string, + ) => Effect.Effect, ManagedEndpointDnsClientError>; + readonly createRecord: ( + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; + readonly updateRecord: ( + dnsRecordId: string, + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect; + readonly deleteRecord: ( + dnsRecordId: string, + ) => Effect.Effect; + } >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {} +export const makeDnsClient = (client: ManagedEndpointDnsClient["Service"]) => + ManagedEndpointDnsClient.of(client); + +export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) => + Layer.succeed(ManagedEndpointDnsClient, makeDnsClient(client)); + const requireCloudflareSettings = Effect.fnUntraced(function* ( - settings: RelayConfiguration.RelayConfigurationShape, + settings: RelayConfiguration.RelayConfiguration["Service"], ) { if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { return yield* new ManagedEndpointProvisioningNotConfigured(); @@ -234,7 +242,7 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const tunnels = yield* ManagedEndpointTunnelClient; const dns = yield* ManagedEndpointDnsClient; - const allocations = yield* ManagedEndpointAllocations; + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; const updateExistingDnsRecords = Effect.fnUntraced(function* ( records: ReadonlyArray<{ readonly id: string }>, @@ -452,8 +460,7 @@ export const layerCloudflareBindings = ( layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed( - ManagedEndpointTunnelClient, + layerTunnelClient( ManagedEndpointTunnelClient.of({ list: (request) => tunnelClient.list(request).pipe( @@ -482,8 +489,7 @@ export const layerCloudflareBindings = ( ), }), ), - Layer.succeed( - ManagedEndpointDnsClient, + layerDnsClient( ManagedEndpointDnsClient.of({ listRecords: (hostname) => dnsClient.listDnsRecords({ search: hostname }).pipe( diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 6e7473b68d9..6061c6e8174 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -30,7 +30,7 @@ vi.mock("@clerk/backend", () => ({ verifyToken: vi.fn(), })); -const relaySettings: RelayConfiguration.RelayConfigurationShape = { +const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { relayIssuer: "https://relay.example.test", apns: { teamId: "apns-team", @@ -110,7 +110,7 @@ describe("relay environment authentication", () => { const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ cause: "database unavailable", }); - const credentials: EnvironmentCredentials.EnvironmentCredentialsShape = { + const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = { create: () => Effect.die("unused create"), authenticate: () => Effect.fail(failure), revokeForEnvironmentPublicKey: () => Effect.die("unused revoke"), diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index cc34e315aca..adb13e828dd 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -66,7 +66,7 @@ import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllo import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; import { withSpanAttributes } from "../observability.ts"; -import { RelayDb } from "../db.ts"; +import * as RelayDb from "../db.ts"; const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; const relayCorsAllowedHeaders = [ @@ -347,7 +347,7 @@ export const healthApi = HttpApiBuilder.group( RelayApi, "health", Effect.fnUntraced(function* (handlers) { - const db = yield* RelayDb; + const db = yield* RelayDb.RelayDb; return handlers.handle( "health", Effect.fn("relay.api.health")( @@ -985,7 +985,10 @@ function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): audience.some((entry) => typeof entry === "string" && entry === expectedAudience); } -function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationShape, token: string) { +function verifyClerkBearerToken( + config: RelayConfiguration.RelayConfiguration["Service"], + token: string, +) { return Effect.tryPromise({ try: () => verifyToken(token, { @@ -1001,7 +1004,7 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha } function verifyClerkOAuthBearerToken( - config: RelayConfiguration.RelayConfigurationShape, + config: RelayConfiguration.RelayConfiguration["Service"], token: string, ) { return Effect.tryPromise({ @@ -1027,7 +1030,7 @@ function verifyClerkOAuthBearerToken( } export function verifyRelayClientBearerToken( - config: RelayConfiguration.RelayConfigurationShape, + config: RelayConfiguration.RelayConfiguration["Service"], token: string, ) { return verifyClerkBearerToken(config, token).pipe( diff --git a/infra/relay/src/observability.test.ts b/infra/relay/src/observability.test.ts index ff543672f7f..5daeda11660 100644 --- a/infra/relay/src/observability.test.ts +++ b/infra/relay/src/observability.test.ts @@ -9,7 +9,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import type { OtlpTracer } from "effect/unstable/observability"; -import { EnvironmentConnectNotAuthorized } from "./environments/EnvironmentConnector.ts"; +import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; import { makeRelayTraceLayer } from "./observability.ts"; interface ExportedRequest { @@ -43,7 +43,7 @@ it.effect("exports schema error fields as span attributes", () => ); yield* Effect.fail( - new EnvironmentConnectNotAuthorized({ + new EnvironmentConnector.EnvironmentConnectNotAuthorized({ environmentId: "environment-1", operation: "connect", reason: "managed_endpoint_allocation_not_ready", diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 2c11d5066ec..0b4f1d1bbc0 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -42,7 +42,7 @@ import * as EnvironmentCredentials from "./environments/EnvironmentCredentials.t import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; import * as LiveActivities from "./agentActivity/LiveActivities.ts"; -import { RelayDb, RelayHyperdrive } from "./db.ts"; +import * as RelayDb from "./db.ts"; import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; @@ -138,7 +138,7 @@ export default class Api extends Cloudflare.Worker()( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; - const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); + const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayDb.RelayHyperdrive); const db = yield* Drizzle.postgres(hyperdrive.connectionString); const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); @@ -203,16 +203,11 @@ export default class Api extends Cloudflare.Worker()( Layer.provideMerge(AgentActivityRows.layer), Layer.provideMerge(Devices.layer), Layer.provideMerge(EnvironmentCredentials.layer), - Layer.provideMerge( - Layer.mergeAll( - EnvironmentLinks.layer, - ManagedEndpointAllocations.ManagedEndpointAllocations.layer, - ), - ), + Layer.provideMerge(Layer.mergeAll(EnvironmentLinks.layer, ManagedEndpointAllocations.layer)), Layer.provideMerge(LiveActivities.layer), Layer.provideMerge(DeliveryAttempts.layer), Layer.provideMerge(RelayTokens.layer), - Layer.provideMerge(Layer.succeed(RelayDb, db)), + Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)), Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), Layer.provideMerge(webcryptoLayer), ); From 1cf3647da62ee4c363f7c3d963c709dbddaa6297 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:58:48 -0700 Subject: [PATCH 026/142] [codex] Normalize server core Effect service modules (#3187) Co-authored-by: codex --- apps/server/src/auth/EnvironmentAuth.test.ts | 19 +- .../src/auth/EnvironmentAuthAdmin.test.ts | 13 +- .../src/auth/EnvironmentAuthPolicy.test.ts | 13 +- .../server/src/auth/PairingGrantStore.test.ts | 13 +- apps/server/src/auth/SessionStore.test.ts | 17 +- apps/server/src/bin.test.ts | 25 +-- apps/server/src/cli/auth.ts | 6 +- apps/server/src/cli/config.ts | 32 ++- apps/server/src/cli/connect.ts | 18 +- apps/server/src/cli/project.ts | 35 ++-- apps/server/src/config.ts | 159 +++++++------- .../Layers/ServerEnvironment.test.ts | 18 +- apps/server/src/keybindings.test.ts | 103 +++++---- apps/server/src/keybindings.ts | 130 ++++++------ .../provider/Layers/ProviderRegistry.test.ts | 192 ++++++++++------- .../src/provider/providerUpdateSettings.ts | 4 +- apps/server/src/server.test.ts | 195 +++++++++--------- apps/server/src/server.ts | 34 +-- apps/server/src/serverLifecycleEvents.test.ts | 6 +- apps/server/src/serverLifecycleEvents.ts | 71 +++---- apps/server/src/serverRuntimeStartup.test.ts | 74 +++---- apps/server/src/serverRuntimeStartup.ts | 101 ++++----- apps/server/src/serverRuntimeState.ts | 6 +- apps/server/src/serverSettings.test.ts | 36 ++-- apps/server/src/serverSettings.ts | 113 +++++----- 25 files changed, 725 insertions(+), 708 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..b917cadb980 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, diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..7dcc89761be 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -3,31 +3,30 @@ 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-" })), ); 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/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..12b0060094a 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), diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..967766a7a4e 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/Services/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([]), diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 27a1d55e90d..64d366468f9 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -20,8 +20,8 @@ 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"; @@ -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(SqlitePersistenceLayerLive), ), WorkspacePathsLive, - ).pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), - ); + ).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( @@ -238,7 +235,7 @@ 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); + const { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); mkdirSync(secretsDir, { recursive: true }); writeFileSync( join(secretsDir, "cloud-cli-oauth-token.bin"), @@ -282,7 +279,7 @@ 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 { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); const tokenPath = join(secretsDir, "cloud-cli-oauth-token.bin"); mkdirSync(secretsDir, { recursive: true }); writeFileSync(tokenPath, "invalid persisted token"); @@ -461,7 +458,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, 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 9c8fb17a18b..51582965913 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -31,9 +31,9 @@ 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 * as ServerConfig from "../config.ts"; import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -145,7 +145,7 @@ const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( function* ( - relayClient: RelayClient.RelayClientShape, + relayClient: RelayClient.RelayClient["Service"], confirmInstall: (version: string) => Effect.Effect, reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, ) { @@ -164,7 +164,7 @@ export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_clie ); const withCloudCliSessionToken = ( - environmentAuth: EnvironmentAuth.EnvironmentAuthShape, + environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, ) => Effect.acquireUseRelease( @@ -183,7 +183,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 +219,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 +285,8 @@ const runCloudCommand = ( | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment - | ServerConfig - | ServerEnvironment + | ServerConfig.ServerConfig + | ServerEnvironment.ServerEnvironment >, options?: { readonly quietLogs?: boolean; @@ -305,7 +305,7 @@ const runCloudCommand = ( 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)); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 0d8e7eca15d..eec7f3f5541 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -26,19 +26,19 @@ 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 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/Services/WorkspacePaths.ts"; import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; type ProjectMutationTarget = { @@ -78,7 +78,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 +123,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 +211,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 +254,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 +285,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), @@ -296,7 +303,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( Effect.provide( Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).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 +348,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/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/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index 6904c53c847..3bb96a83e1c 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -8,15 +8,15 @@ 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 * as ServerConfig from "../../config.ts"; +import * as 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); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); return { ...derivedPaths, @@ -44,7 +44,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { devUrl: undefined, noBrowser: false, startupPresentation: "browser", - } satisfies ServerConfigShape; + } satisfies ServerConfig.ServerConfig["Service"]; }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { @@ -56,11 +56,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const first = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; return yield* serverEnvironment.getDescriptor; }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); const second = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; return yield* serverEnvironment.getDescriptor; }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); @@ -109,14 +109,12 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const exit = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; return yield* serverEnvironment.getDescriptor; }).pipe( Effect.provide( ServerEnvironmentLive.pipe( - Layer.provide( - Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), - ), + Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), ), ), Effect.exit, 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/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5fe0f903686..1805b6ed277 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"), diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts index 564af26c78e..308d84a1446 100644 --- a/apps/server/src/provider/providerUpdateSettings.ts +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Stream from "effect/Stream"; -import type { ServerSettingsShape } from "../serverSettings.ts"; +import type * as ServerSettingsModule from "../serverSettings.ts"; export interface ProviderSnapshotSettings { readonly provider: Settings; @@ -29,7 +29,7 @@ export function haveProviderSnapshotSettingsChanged( export function makeProviderSnapshotSettingsSource( provider: Settings, - serverSettings: ServerSettingsShape, + serverSettings: ServerSettingsModule.ServerSettingsService["Service"], ): { readonly getSettings: Effect.Effect, ServerSettingsError>; readonly streamSettings: Stream.Stream>; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 205833289ea..bf4a77743a2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -69,56 +69,30 @@ 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/Services/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/Services/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 * as BrowserTraceCollector 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 ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.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,7 +481,7 @@ 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( @@ -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, }), @@ -5938,14 +5917,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const fetchRemote = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.sync(() => { bootstrapGitOperations.push("fetch"); }), ); const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; const resolveRemoteTrackingCommit = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.sync(() => { bootstrapGitOperations.push("resolve-remote-commit"); return { @@ -5955,7 +5934,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const createWorktree = vi.fn( - (_: Parameters[0]) => + (_: Parameters[0]) => Effect.sync(() => { bootstrapGitOperations.push("create-worktree"); return { @@ -5967,7 +5946,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", @@ -6101,7 +6084,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", @@ -6110,8 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); const runForThread = vi.fn( - (_: Parameters[0]) => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "pty unavailable" })), + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ + message: "pty unavailable", + }), + ), ); yield* buildAppUnderTest({ @@ -6195,7 +6186,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", @@ -6204,7 +6195,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", @@ -6314,7 +6309,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 1da0ea27a65..373dc61bad8 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,15 +16,15 @@ 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 * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/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 * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -40,8 +40,8 @@ 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,7 +51,7 @@ 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 * as ServerSettings from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -112,14 +112,14 @@ const PtyAdapterLive = Layer.unwrap( 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"), @@ -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,14 +305,14 @@ 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), @@ -334,11 +334,11 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsServiceLayerLive), 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..a11beba794d 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/Services/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,13 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => .pipe(Effect.forkScoped); yield* commandGate.failCommandReady( - new ServerRuntimeStartupError({ - message: "startup failed", + new ServerRuntimeStartup.ServerRuntimeStartupError({ + stage: "command-readiness", }), ); 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 +72,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 +94,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 +105,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 +124,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 +142,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 +156,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 +178,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 +198,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 +226,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 +246,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..dab6143e11c 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 ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; +import * as AnalyticsService from "./telemetry/Services/AnalyticsService.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,30 @@ 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", + { + stage: Schema.Literal("command-readiness"), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.stage) { + case "command-readiness": + 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 +132,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 +168,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 +181,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 +251,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 +268,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 +289,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; +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 +417,7 @@ 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.", + stage: "command-readiness", cause: startupExit.cause, }); yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); @@ -461,10 +469,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 6e1ceb16a8d..a5fcdc30c02 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -26,26 +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"; @@ -109,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); @@ -255,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; @@ -578,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); From d2c0a6a48f50f319815a8cf5501f9e2e9b8ca617 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 20:02:20 -0700 Subject: [PATCH 027/142] Add diff scope switching and provider update settings (#3169) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 73 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 31 +- apps/web/src/components/ChatView.tsx | 133 +-- apps/web/src/components/DiffPanel.tsx | 781 ++++++++++-------- apps/web/src/components/DiffPanelShell.tsx | 10 +- .../components/diffs/AnnotatableFileDiff.tsx | 211 ++++- apps/web/src/diffPanelStore.test.ts | 68 ++ apps/web/src/diffPanelStore.ts | 139 ++++ apps/web/src/diffRouteSearch.test.ts | 74 -- apps/web/src/diffRouteSearch.ts | 39 - apps/web/src/index.css | 3 +- apps/web/src/lib/baseRefChoices.test.ts | 52 ++ apps/web/src/lib/baseRefChoices.ts | 61 ++ apps/web/src/lib/diffRendering.test.ts | 55 +- apps/web/src/lib/diffRendering.ts | 42 +- apps/web/src/rightPanelStore.test.ts | 11 - apps/web/src/rightPanelStore.ts | 10 - .../routes/_chat.$environmentId.$threadId.tsx | 7 +- packages/contracts/src/git.ts | 2 + packages/contracts/src/review.ts | 1 + 20 files changed, 1179 insertions(+), 624 deletions(-) create mode 100644 apps/web/src/diffPanelStore.test.ts create mode 100644 apps/web/src/diffPanelStore.ts delete mode 100644 apps/web/src/diffRouteSearch.test.ts delete mode 100644 apps/web/src/diffRouteSearch.ts create mode 100644 apps/web/src/lib/baseRefChoices.test.ts create mode 100644 apps/web/src/lib/baseRefChoices.ts diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 41f5d595f0a..5be6427fe73 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", () => { @@ -342,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(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5c24072052d..b78fba1030e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1817,7 +1817,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, @@ -1843,7 +1850,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, @@ -2127,11 +2140,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, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1ef90c4309..63674076151 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,7 +42,7 @@ import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/ter 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 { isAtomCommandInterrupted, @@ -55,7 +55,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -104,7 +104,7 @@ 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, @@ -1034,10 +1034,6 @@ function ChatViewContent(props: ChatViewProps) { 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( @@ -1217,7 +1213,6 @@ function ChatViewContent(props: ChatViewProps) { 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, @@ -1259,8 +1254,9 @@ function ChatViewContent(props: ChatViewProps) { ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeRightPanelKind = useRightPanelStore((state) => - selectActiveRightPanelKindWithUrl(state.byThreadKey, activeThreadRef, diffOpen), + selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); + const diffOpen = activeRightPanelKind === "diff"; const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -1295,11 +1291,6 @@ function ChatViewContent(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; - useEffect(() => { - if (!activeThreadRef || !diffOpen) return; - useRightPanelStore.getState().open(activeThreadRef, "diff"); - }, [activeThreadRef, diffOpen]); - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -2151,27 +2142,7 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef) { useRightPanelStore.getState().toggle(activeThreadRef, "diff"); } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [ - activeThreadRef, - diffOpen, - environmentId, - isServerThread, - navigate, - onDiffPanelOpen, - threadId, - ]); + }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); const envLocked = Boolean( activeThread && @@ -2757,21 +2728,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), - }); - }, [ - activeThreadRef, - environmentId, - isGitRepo, - isServerThread, - navigate, - onDiffPanelOpen, - threadId, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -2789,30 +2746,13 @@ function ChatViewContent(props: ChatViewProps) { useRightPanelStore.getState().close(activeThreadRef); return; } - if (diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } const activeTabId = activePreviewState.activeTabId; if (activeTabId) { useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [ - activePreviewState.activeTabId, - activeThreadRef, - createBrowserSurface, - diffOpen, - environmentId, - navigate, - previewPanelOpen, - threadId, - ]); + }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { setMaximizedRightPanelThreadKey(null); @@ -2936,31 +2876,9 @@ function ChatViewContent(props: ChatViewProps) { } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), - }); - } else if (surface.kind !== "diff" && diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); } }, - [ - activeThreadRef, - diffOpen, - dismissPlanSidebarForCurrentTurn, - environmentId, - navigate, - onDiffPanelOpen, - planSidebarOpen, - threadId, - ], + [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; @@ -3006,26 +2924,14 @@ function ChatViewContent(props: ChatViewProps) { } } } - if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } }, [ activeThreadRef, activePreviewState.sessions, closePreview, closeTerminalMutation, - diffOpen, dismissPlanSidebarForCurrentTurn, - environmentId, - navigate, storeCloseTerminal, - threadId, ], ); const syncActivePreviewSurface = useCallback(() => { @@ -4631,25 +4537,12 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - if (!isServerThread) { - return; - } + if (!isServerThread || !activeThreadRef) return; + useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); + useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], + [activeThreadRef, isServerThread, onDiffPanelOpen], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7ea2d588477..a7309b44f4c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,35 +1,28 @@ import { useAtomValue } from "@effect/atom-react"; -import { Virtualizer } from "@pierre/diffs/react"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useParams } from "@tanstack/react-router"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; 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 { 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 { cn } from "~/lib/utils"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, @@ -40,19 +33,41 @@ import { } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } 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; @@ -167,27 +182,24 @@ interface DiffPanelProps { export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { - const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ scopeKey: null, fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); - const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); + 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 diffSelection = useDiffPanelStore((state) => + selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), + ); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; @@ -233,8 +245,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 @@ -243,7 +267,16 @@ 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; @@ -253,7 +286,9 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff : EMPTY_COLLAPSED_DIFF_FILE_KEYS; const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : "All turns"; + : selectedGitScope === "unstaged" + ? "Working tree" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -264,62 +299,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") { @@ -332,24 +421,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 (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) => { @@ -385,186 +476,190 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff ); const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); + const selectGitScope = (scope: "branch" | "unstaged") => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); + }; + const selectBranchBaseRef = (baseRef: string | null) => { + if (!routeThreadRef) return; + useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> -
- - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - selectTurn(summary.turnId)} - data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} - /> - } + Latest turn + {selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId && ( + + )} + + + Turn + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + selectTurn(summary.turnId)} + > + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + {summary.turnId === selectedTurn?.turnId && } + + ); + })} + + + + + {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( +
+ {selectedGitSource.headRef ?? "HEAD"} + + { + if (!open) setBaseRefQuery(""); + }} + onValueChange={(value) => { + if (!value) return; + selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); + }} + > + + {selectedGitSource.baseRef} + + + -
-
- - Turn{" "} - {summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"} - - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - +
+
+
- - {summary.turnId} - - ))} -
+
+
+ No matching refs. + + + Automatic + + {baseRefChoices.map((choice) => { + const item = valueForBaseRefChoice(choice); + const hasBoth = choice.local !== null && choice.remote !== null; + const useRemote = choice.remote?.name === item; + return ( + +
+ {choice.label} + {hasBoth ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + const nextRef = checked + ? choice.remote?.name + : choice.local?.name; + if (nextRef) selectBranchBaseRef(nextRef); + }} + /> +
+ ) : choice.remote ? ( + + + ) : null} +
+
+ ); + })} +
+ + +
+ )}
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 ? ( + ) : (

@@ -672,88 +778,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}

-      
- - -
- - - -
+
+
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/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts new file mode 100644 index 00000000000..64846e8e9f1 --- /dev/null +++ b/apps/web/src/diffPanelStore.test.ts @@ -0,0 +1,68 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore"; + +const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1")); + +describe("diffPanelStore", () => { + beforeEach(() => useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} })); + + it("defaults each thread to branch changes with automatic base selection", () => { + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: null }); + }); + + it("clears incompatible selection fields when changing scopes", () => { + const store = useDiffPanelStore.getState(); + store.selectTurn(THREAD_REF, TurnId.make("turn-1"), "src/app.ts"); + store.selectGitScope(THREAD_REF, "unstaged"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "unstaged" }); + + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, " origin/main "); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/main" }); + }); + + it("increments the reveal request when opening the same turn file again", () => { + const turnId = TurnId.make("turn-1"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, turnId, "src/app.ts"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "turn", turnId, filePath: "src/app.ts", revealRequestId: 2 }); + }); + + it("restores the selected branch base after visiting another scope", () => { + useDiffPanelStore.getState().selectBranchBaseRef(THREAD_REF, "origin/main"); + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "unstaged"); + useDiffPanelStore.getState().selectGitScope(THREAD_REF, "branch"); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ kind: "branch", baseRef: "origin/main" }); + }); + + it("reconciles a missing turn selection to the latest available turn", () => { + const missingTurnId = TurnId.make("turn-missing"); + const latestTurnId = TurnId.make("turn-latest"); + useDiffPanelStore.getState().selectTurn(THREAD_REF, missingTurnId, "src/app.ts"); + useDiffPanelStore.getState().reconcileTurnSelection(THREAD_REF, [latestTurnId]); + + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + kind: "turn", + turnId: latestTurnId, + filePath: "src/app.ts", + revealRequestId: 1, + }); + }); +}); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts new file mode 100644 index 00000000000..c946b286d1b --- /dev/null +++ b/apps/web/src/diffPanelStore.ts @@ -0,0 +1,139 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export type DiffPanelSelection = + | { kind: "branch"; baseRef: string | null } + | { kind: "unstaged" } + | { kind: "turn"; turnId: TurnId; filePath: string | null; revealRequestId: number }; + +const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; + +interface DiffPanelStoreState { + byThreadKey: Record; + branchBaseRefByThreadKey: Record; + selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; + selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; + selectTurn: (ref: ScopedThreadRef, turnId: TurnId, filePath?: string) => void; + reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +function normalizeBaseRef(baseRef: string | null): string | null { + const normalized = baseRef?.trim(); + return normalized ? normalized : null; +} + +export const useDiffPanelStore = create()( + persist( + (set) => ({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + selectGitScope: (ref, scope) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + const previousBaseRef = + previous?.kind === "branch" + ? previous.baseRef + : (state.branchBaseRefByThreadKey[threadKey] ?? null); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: + scope === "branch" + ? { kind: "branch", baseRef: previousBaseRef } + : { kind: "unstaged" }, + }, + branchBaseRefByThreadKey: + previous?.kind === "branch" + ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } + : state.branchBaseRefByThreadKey, + }; + }), + selectBranchBaseRef: (ref, baseRef) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const normalizedBaseRef = normalizeBaseRef(baseRef); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { kind: "branch", baseRef: normalizedBaseRef }, + }, + branchBaseRefByThreadKey: { + ...state.branchBaseRefByThreadKey, + [threadKey]: normalizedBaseRef, + }, + }; + }), + selectTurn: (ref, turnId, filePath) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { + kind: "turn", + turnId, + filePath: filePath?.trim() || null, + revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, + }, + }, + }; + }), + reconcileTurnSelection: (ref, availableTurnIds) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const previous = state.byThreadKey[threadKey]; + const latestTurnId = availableTurnIds[0]; + if ( + previous?.kind !== "turn" || + latestTurnId === undefined || + availableTurnIds.includes(previous.turnId) + ) { + return state; + } + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { ...previous, turnId: latestTurnId }, + }, + }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey) && !(threadKey in state.branchBaseRefByThreadKey)) { + return state; + } + const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey; + const { [threadKey]: _removedBaseRef, ...branchBaseRefByThreadKey } = + state.branchBaseRefByThreadKey; + return { byThreadKey, branchBaseRefByThreadKey }; + }), + }), + { + name: "t3code:diff-panel-state:v1", + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byThreadKey: state.byThreadKey, + branchBaseRefByThreadKey: state.branchBaseRefByThreadKey, + }), + }, + ), +); + +export function selectThreadDiffPanelSelection( + byThreadKey: Record, + ref: ScopedThreadRef | null | undefined, +): DiffPanelSelection { + if (!ref) return DEFAULT_SELECTION; + return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; +} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts deleted file mode 100644 index c80368eeea4..00000000000 --- a/apps/web/src/diffRouteSearch.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { parseDiffRouteSearch } from "./diffRouteSearch"; - -describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - }); - - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); - }); - - it("drops file value when turn is not selected", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); - - it("normalizes whitespace-only values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: " ", - diffFilePath: " ", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); -}); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts deleted file mode 100644 index d9b072f28e1..00000000000 --- a/apps/web/src/diffRouteSearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; - -export interface DiffRouteSearch { - diff?: "1" | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripDiffSearchParams>( - params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; -} - -export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(diff ? { diff } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9048e2074ed..09ef006a638 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -689,7 +689,8 @@ label:has(> select#reasoning-effort) select { background: color-mix(in srgb, var(--background) 94%, var(--card)); } -.diff-render-file { +.diff-render-file, +.diff-render-surface > diffs-container { border: 1px solid var(--border); border-radius: 0.5rem; overflow: clip; diff --git a/apps/web/src/lib/baseRefChoices.test.ts b/apps/web/src/lib/baseRefChoices.test.ts new file mode 100644 index 00000000000..90f84d900f4 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { VcsRef } from "@t3tools/contracts"; +import { buildBaseRefChoices, filterBaseRefChoices } from "./baseRefChoices"; + +function ref(name: string, remoteName?: string): VcsRef { + return { + name, + current: false, + isDefault: false, + isRemote: remoteName !== undefined, + ...(remoteName ? { remoteName } : {}), + worktreePath: null, + }; +} + +describe("buildBaseRefChoices", () => { + it("pairs matching local and remote branches and prefers origin", () => { + const choices = buildBaseRefChoices( + [ref("main")], + [ref("upstream/main", "upstream"), ref("origin/main", "origin")], + ); + + expect(choices).toEqual([ + expect.objectContaining({ + label: "main", + local: expect.objectContaining({ name: "main" }), + remote: expect.objectContaining({ name: "origin/main" }), + }), + expect.objectContaining({ + label: "upstream/main", + local: null, + remote: expect.objectContaining({ name: "upstream/main" }), + }), + ]); + }); +}); + +describe("filterBaseRefChoices", () => { + it("filters stale server results against the current query", () => { + const choices = buildBaseRefChoices( + [ref("main"), ref("feature/search")], + [ref("origin/main", "origin"), ref("origin/feature/search", "origin")], + ); + + expect(filterBaseRefChoices(choices, "SEARCH").map((choice) => choice.label)).toEqual([ + "feature/search", + ]); + expect(filterBaseRefChoices(choices, "origin/main").map((choice) => choice.label)).toEqual([ + "main", + ]); + }); +}); diff --git a/apps/web/src/lib/baseRefChoices.ts b/apps/web/src/lib/baseRefChoices.ts new file mode 100644 index 00000000000..2be010040a3 --- /dev/null +++ b/apps/web/src/lib/baseRefChoices.ts @@ -0,0 +1,61 @@ +import type { VcsRef } from "@t3tools/contracts"; + +export interface BaseRefChoice { + readonly id: string; + readonly label: string; + readonly local: VcsRef | null; + readonly remote: VcsRef | null; +} + +function remoteBranchName(ref: VcsRef): string { + if (ref.remoteName && ref.name.startsWith(`${ref.remoteName}/`)) { + return ref.name.slice(ref.remoteName.length + 1); + } + return ref.name; +} + +export function buildBaseRefChoices( + localRefs: ReadonlyArray, + remoteRefs: ReadonlyArray, +): ReadonlyArray { + const unusedRemoteRefs = new Set(remoteRefs); + const pairedChoices = localRefs.map((local) => { + const matches = remoteRefs.filter( + (remote) => unusedRemoteRefs.has(remote) && remoteBranchName(remote) === local.name, + ); + const remote = + matches.find((candidate) => candidate.remoteName === "origin") ?? matches[0] ?? null; + if (remote) unusedRemoteRefs.delete(remote); + return { + id: `local:${local.name}`, + label: local.name, + local, + remote, + }; + }); + + const remoteOnlyChoices = remoteRefs + .filter((remote) => unusedRemoteRefs.has(remote)) + .map((remote) => ({ + id: `remote:${remote.name}`, + label: remote.name, + local: null, + remote, + })); + + return [...pairedChoices, ...remoteOnlyChoices]; +} + +export function filterBaseRefChoices( + choices: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (normalizedQuery.length === 0) return choices; + return choices.filter( + (choice) => + choice.label.toLocaleLowerCase().includes(normalizedQuery) || + choice.local?.name.toLocaleLowerCase().includes(normalizedQuery) === true || + choice.remote?.name.toLocaleLowerCase().includes(normalizedQuery) === true, + ); +} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index c24f58b99dd..e75a893d6b3 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPatchCacheKey } from "./diffRendering"; +import { buildPatchCacheKey, getRenderablePatch } from "./diffRendering"; describe("buildPatchCacheKey", () => { it("returns a stable cache key for identical content", () => { @@ -29,3 +29,56 @@ describe("buildPatchCacheKey", () => { ); }); }); + +describe("getRenderablePatch", () => { + it("compacts partial hunk render offsets for virtualized review diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "index 1111111..2222222 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,4 +48,4 @@", + " context", + "-before", + "+after", + " context", + " context", + "@@ -80,3 +80,4 @@", + " context", + "+added", + " context", + " context", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "review", { + compactPartialHunkOffsets: true, + }); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + + const file = parsed.files[0]; + expect(file?.hunks[0]?.collapsedBefore).toBe(47); + expect(file?.hunks[0]?.unifiedLineStart).toBe(0); + expect(file?.hunks[1]?.collapsedBefore).toBeGreaterThan(0); + expect(file?.hunks[1]?.unifiedLineStart).toBe(file?.hunks[0]?.unifiedLineCount); + expect(file?.unifiedLineCount).toBe( + file?.hunks.reduce((total, hunk) => total + hunk.unifiedLineCount, 0), + ); + }); + + it("retains source-file offsets for checkpoint diffs", () => { + const patch = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -48,1 +48,1 @@", + "-before", + "+after", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "checkpoint"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb57ec7e065..cb8318b3d2d 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -52,9 +52,45 @@ export type RenderablePatch = reason: string; }; +interface RenderablePatchOptions { + /** + * Pierre's partial-patch parser keeps hunk render starts in source-file + * coordinates. Its virtualizer iterates partial patches as compact rows, so + * review diffs need compact render starts while retaining collapsedBefore + * for the "N unmodified lines" separator. + */ + compactPartialHunkOffsets?: boolean; +} + +export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { + if (!file.isPartial) return file; + + let splitLineStart = 0; + let unifiedLineStart = 0; + const hunks = file.hunks.map((hunk) => { + const compactHunk = { + ...hunk, + splitLineStart, + unifiedLineStart, + }; + splitLineStart += hunk.splitLineCount; + unifiedLineStart += hunk.unifiedLineCount; + return compactHunk; + }); + + return { + ...file, + hunks, + splitLineCount: splitLineStart, + unifiedLineCount: unifiedLineStart, + ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}), + }; +} + export function getRenderablePatch( patch: string | undefined, cacheScope = "diff-panel", + options: RenderablePatchOptions = {}, ): RenderablePatch | null { if (!patch) return null; const normalizedPatch = patch.trim(); @@ -65,7 +101,11 @@ export function getRenderablePatch( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); - const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + const files = parsedPatches.flatMap((parsedPatch) => + options.compactPartialHunkOffsets + ? parsedPatch.files.map(compactPartialHunkOffsets) + : parsedPatch.files, + ); if (files.length > 0) { return { kind: "files", files }; } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 3b6dcc347e4..fb6d56f98c7 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -6,7 +6,6 @@ import { migratePersistedRightPanelState, selectActiveRightPanel, selectActiveRightPanelSurface, - selectActiveRightPanelKindWithUrl, selectThreadRightPanelState, useRightPanelStore, } from "./rightPanelStore"; @@ -254,16 +253,6 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); }); - it("?diff=1 always wins over persisted state", () => { - useRightPanelStore.getState().open(refA, "preview"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), - ).toBe("diff"); - expect( - selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), - ).toBe("preview"); - }); - it("removeThread clears persisted state", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().removeThread(refA); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 36fa82f9ff8..26dfe8c5153 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -550,13 +550,3 @@ export function selectActiveRightPanelSurface( if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; } - -export function selectActiveRightPanelKindWithUrl( - byThreadKey: Record, - ref: ScopedThreadRef | null | undefined, - diffSearchActive: boolean, -): RightPanelKind | null { - if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; - if (diffSearchActive) return "diff"; - return selectActiveRightPanel(byThreadKey, ref); -} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 5640487b31b..7dc6702b4ec 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,10 +1,9 @@ -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; import { resolveThreadRouteRef } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; @@ -74,9 +73,5 @@ function ChatThreadRouteView() { } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..3de6c84fa44 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -125,6 +125,8 @@ export const VcsListRefsInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), cursor: Schema.optional(NonNegativeInt), + includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), + refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), limit: Schema.optional( PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 363b124bf22..a6b879a0c7f 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -6,6 +6,7 @@ import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; From 3b56dc17a5550de0f3ffd376ed2785cc12fbb96b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 20:07:29 -0700 Subject: [PATCH 028/142] [codex] Refactor primary HTTP Effect service (#3205) Co-authored-by: codex --- apps/web/src/environments/primary/httpClient.ts | 13 +++++-------- apps/web/src/lib/runtime.ts | 14 ++++++-------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/web/src/environments/primary/httpClient.ts b/apps/web/src/environments/primary/httpClient.ts index d5cb22433c4..a7860ec610c 100644 --- a/apps/web/src/environments/primary/httpClient.ts +++ b/apps/web/src/environments/primary/httpClient.ts @@ -5,16 +5,13 @@ import * as Layer from "effect/Layer"; import { resolvePrimaryEnvironmentHttpUrl } from "./target"; -export type PrimaryEnvironmentHttpClientShape = Effect.Success< - ReturnType ->; - export class PrimaryEnvironmentHttpClient extends Context.Service< PrimaryEnvironmentHttpClient, - PrimaryEnvironmentHttpClientShape + Effect.Success> >()("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient") {} -export const primaryEnvironmentHttpClientLive = Layer.effect( - PrimaryEnvironmentHttpClient, - Effect.suspend(() => makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"))), +const make = Effect.suspend(() => + makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")), ); + +export const layer = Layer.effect(PrimaryEnvironmentHttpClient, make); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index cf4ffb0845d..e4bea61f143 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -5,10 +5,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; -import { - PrimaryEnvironmentHttpClient, - primaryEnvironmentHttpClientLive, -} from "../environments/primary/httpClient"; +import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { browserCryptoLayer } from "../cloud/dpop"; @@ -30,11 +27,11 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig( export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( - primaryEnvironmentHttpClientLive.pipe(Layer.provide(primaryEnvironmentHttpLayer)), + PrimaryEnvironmentHttpClient.layer.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); export type PrimaryHttpEffectRunner = ( - effect: Effect.Effect, + effect: Effect.Effect, ) => Promise; const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) => @@ -42,8 +39,9 @@ const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) => let primaryHttpRunner = livePrimaryHttpRunner; -export const runPrimaryHttp = (effect: Effect.Effect) => - primaryHttpRunner(effect); +export const runPrimaryHttp = ( + effect: Effect.Effect, +) => primaryHttpRunner(effect); export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { primaryHttpRunner = runner ?? livePrimaryHttpRunner; From 938b19a6bb2e361845cb5a497174d4062549c39c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 20:10:33 -0700 Subject: [PATCH 029/142] [codex] Normalize Desktop IPC Effect service (#3203) Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpc.ts | 30 ++++++------ .../desktop/src/ipc/methods/clientSettings.ts | 6 +-- .../src/ipc/methods/connectionCatalog.ts | 8 ++-- apps/desktop/src/ipc/methods/preview.ts | 46 +++++++++---------- .../desktop/src/ipc/methods/serverExposure.ts | 10 ++-- .../desktop/src/ipc/methods/sshEnvironment.ts | 18 ++++---- apps/desktop/src/ipc/methods/updates.ts | 12 ++--- apps/desktop/src/ipc/methods/window.ts | 18 ++++---- apps/desktop/src/main.ts | 2 +- 9 files changed, 76 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 6d954a97aec..253bb2774e9 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"; @@ -33,20 +34,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 const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => +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): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -97,6 +97,8 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => }), }); +export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); + /** * Convenience helpers for creating IPC methods */ 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/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index c779c554ffd..4e51496a637 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -4,9 +4,9 @@ import * as Schema from "effect/Schema"; import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts"; import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; -export const getConnectionCatalog = makeIpcMethod({ +export const getConnectionCatalog = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL, payload: Schema.Void, result: Schema.NullOr(Schema.String), @@ -16,7 +16,7 @@ export const getConnectionCatalog = makeIpcMethod({ }), }); -export const setConnectionCatalog = makeIpcMethod({ +export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, payload: Schema.String, result: Schema.Boolean, @@ -26,7 +26,7 @@ export const setConnectionCatalog = makeIpcMethod({ }), }); -export const clearConnectionCatalog = makeIpcMethod({ +export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, payload: Schema.Void, result: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8adae374ad0..cb6e7c51918 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -27,7 +27,7 @@ import { pathToFileURL } 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, @@ -100,7 +100,7 @@ const tabMethod = ( 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, @@ -201,7 +201,7 @@ export const getPreviewConfig = makeIpcMethod({ }), }); -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/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 2f46b263b0f..9c9af2a4e2b 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -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 708bb299ccc..3cb705d0361 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -18,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, @@ -36,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* () { @@ -45,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* () { @@ -65,7 +65,7 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBearerToken = makeIpcMethod({ +export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, payload: Schema.Void, result: Schema.String, @@ -75,7 +75,7 @@ export const getLocalEnvironmentBearerToken = makeIpcMethod({ }), }); -export const pickFolder = makeIpcMethod({ +export const pickFolder = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), result: Schema.NullOr(Schema.String), @@ -91,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, @@ -104,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, @@ -114,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), @@ -135,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 96461ab841a..9fc364bd066 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -109,7 +109,7 @@ const electronLayer = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), + DesktopIpc.layer(Electron.ipcMain), ); const desktopFoundationLayer = Layer.mergeAll( From 9544e72d08755dfc5d9014efd87243f4654f239d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 19 Jun 2026 22:28:51 -0500 Subject: [PATCH 030/142] chore: run eas only when labelled (#3208) --- .github/workflows/mobile-eas-preview.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From b6302784f8e058de4fb20dae931b4df29e9302fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:04:13 -0700 Subject: [PATCH 031/142] [codex] Refactor review and text generation services (#3196) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 100 ++++----- .../Layers/ProviderAdapterRegistry.test.ts | 35 ++-- apps/server/src/provider/ProviderDriver.ts | 4 +- apps/server/src/review/ReviewService.ts | 29 ++- .../ClaudeTextGeneration.test.ts | 8 +- .../textGeneration/ClaudeTextGeneration.ts | 162 +++++++-------- .../CodexTextGeneration.test.ts | 14 +- .../src/textGeneration/CodexTextGeneration.ts | 188 ++++++++--------- .../CursorTextGeneration.test.ts | 8 +- .../textGeneration/CursorTextGeneration.ts | 158 +++++++------- .../textGeneration/GrokTextGeneration.test.ts | 8 +- .../src/textGeneration/GrokTextGeneration.ts | 158 +++++++------- .../OpenCodeTextGeneration.test.ts | 26 +-- .../textGeneration/OpenCodeTextGeneration.ts | 193 +++++++++--------- .../src/textGeneration/TextGeneration.test.ts | 33 +-- .../src/textGeneration/TextGeneration.ts | 128 ++++++------ 16 files changed, 609 insertions(+), 643 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bee861677a5..cc7340f965a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -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 GitManager from "./GitManager.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 ServerSettings from "../serverSettings.ts"; +import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.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; } @@ -182,13 +171,13 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { 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 && @@ -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.GitHubCliShape; 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.GitHubCliShape["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -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.GitManagerShape, 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.GitManagerShape, + input: { cwd: string; reference: string }, +) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManagerShape, + manager: GitManager.GitManagerShape, input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -650,20 +646,20 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunnerShape; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.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, @@ -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.makeGitManager().pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -701,7 +697,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; const GitManagerTestLayer = GitVcsDriver.layer.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }), + ), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); @@ -1335,7 +1333,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.", }), @@ -2417,7 +2415,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 +2444,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.", }), @@ -2702,7 +2700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 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: { @@ -2924,7 +2922,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const worktreePath = path.join(repoDir, "..", `pr-existing-${path.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: { @@ -3164,7 +3162,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, setupScriptRunner: { runForThread: () => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), + Effect.fail( + new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ + message: "terminal start failed", + }), + ), }, }); 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/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/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 63f1d133213..3f222bd520f 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -13,22 +13,21 @@ 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; @@ -62,7 +61,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 +95,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/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..5365d920471 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -16,8 +16,8 @@ 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); @@ -28,7 +28,7 @@ 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)); @@ -56,7 +56,7 @@ function makeAcpAgentWrapper(dir: string, env: Record): string { 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-")); 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..5df012cca85 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -13,8 +13,8 @@ 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); @@ -25,7 +25,7 @@ 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)); @@ -53,7 +53,7 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { 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-")); 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); From 8bfdacf206608c0e14b1c5471dfa1f482a452acc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:04:49 -0700 Subject: [PATCH 032/142] [codex] Use namespace imports for desktop core services (#3207) Co-authored-by: codex --- apps/desktop/src/app/DesktopApp.ts | 7 ++++--- apps/desktop/src/app/DesktopLifecycle.ts | 10 ++++------ .../src/backend/DesktopServerExposure.test.ts | 20 ++++++++----------- apps/desktop/src/main.ts | 3 ++- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 136a9dfd097..f498c3340e6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,6 +17,7 @@ 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; @@ -237,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/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 89a9389c93f..b62662ad27b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -9,17 +9,15 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; -import * as DesktopShutdownModule from "./DesktopShutdown.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 { DesktopShutdown, layer as layerShutdown } from "./DesktopShutdown.ts"; - export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment - | DesktopShutdownModule.DesktopShutdown + | DesktopShutdown.DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp @@ -63,8 +61,8 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { - const shutdown = yield* DesktopShutdownModule.DesktopShutdown; + function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index e5fbb84c8ad..1dd7fa04a79 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -9,19 +9,15 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -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 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: DesktopServerExposure.DesktopNetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { en0: [ { address: "192.168.1.20", @@ -31,7 +27,7 @@ const lanNetworkInterfaces: DesktopNetworkInterfaces = { ], }; -const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { +const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { tailscale0: [ { address: "100.90.1.2", @@ -72,7 +68,7 @@ function dieOnSpawnLayer() { } function makeEnvironmentLayer(baseDir: string, env: Record = {}) { - return makeDesktopEnvironmentLayer({ + return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, platform: "darwin", @@ -91,7 +87,7 @@ function makeEnvironmentLayer(baseDir: string, env: Record; readonly spawnerLayer?: Layer.Layer; }) { @@ -113,12 +109,12 @@ function makeLayer(input: { } const withHarness = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces, effect: Effect.Effect< A, E, | R - | DesktopEnvironment + | DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | DesktopServerExposure.DesktopServerExposure | DesktopAppSettings.DesktopAppSettings diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9fc364bd066..c9f782d9fc5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.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"; @@ -114,7 +115,7 @@ const electronLayer = Layer.mergeAll( const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, - DesktopLifecycle.layerShutdown, + DesktopShutdown.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), From 5c1ac922574340be4560eacf928237929497d11d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:09:22 -0700 Subject: [PATCH 033/142] [codex] normalize server process and preview Effect services (#3191) Co-authored-by: codex --- .../Layers/ServerEnvironmentLabel.test.ts | 10 +- .../src/mcp/PreviewAutomationBroker.test.ts | 16 +- .../server/src/mcp/PreviewAutomationBroker.ts | 55 ++--- apps/server/src/preview/Manager.ts | 193 +++++++++--------- apps/server/src/preview/PortScanner.ts | 103 +++++----- apps/server/src/process/externalLauncher.ts | 44 ++-- apps/server/src/processRunner.test.ts | 28 +-- apps/server/src/processRunner.ts | 120 +++++++---- 8 files changed, 297 insertions(+), 272 deletions(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 3a4dce1627c..14580369a78 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -5,15 +5,15 @@ import * as Layer from "effect/Layer"; import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; -import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; +import * 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,7 +136,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { runMock.mockReturnValueOnce( Effect.fail( - new ProcessSpawnError({ + new ProcessRunner.ProcessSpawnError({ command: "scutil", args: ["--get", "ComputerName"], cause: new Error("spawn scutil ENOENT"), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 06b18259833..5631b3bef57 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -37,7 +37,7 @@ const makeOwner = (overrides: Partial = {}): PreviewAuto it.effect("atomically registers a connected owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ @@ -61,7 +61,7 @@ it.effect("atomically registers a connected 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); @@ -72,7 +72,7 @@ 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 broker = yield* PreviewAutomationBroker.make; const requests = yield* broker.connect( makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }), ); @@ -89,7 +89,7 @@ it.effect("routes interactive commands to a hidden durable browser host", () => 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.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const requests = yield* broker.connect(makeOwner({ tabId: null })); let routedTabId: string | undefined; yield* Stream.runForEach(requests, (request) => { @@ -108,7 +108,7 @@ it.effect("lets the browser host resolve an active tab that has not been reporte it.effect("preserves current owner metadata when its request stream reconnects", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + 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 })); @@ -131,7 +131,7 @@ it.effect("preserves current owner metadata when its request stream reconnects", it.effect("ignores stale owner cleanup after the client moves to another thread", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const requests = yield* broker.connect(makeOwner()); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), @@ -152,7 +152,7 @@ it.effect("ignores stale owner cleanup after the client moves to another thread" it.effect("fails requests assigned to a browser stream when that stream reconnects", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.__testing.make; + const broker = yield* PreviewAutomationBroker.make; const _requests = yield* broker.connect(makeOwner()); const pending = yield* broker .invoke({ scope, operation: "status", input: {} }) @@ -170,7 +170,7 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec 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.__testing.make; + 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" }), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3cd7563bd9d..ee9d5bdbd0d 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -35,34 +35,28 @@ export interface PreviewAutomationInvokeInput { readonly timeoutMs?: number; } -export interface 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; -} - 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 { @@ -123,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(), @@ -166,7 +160,7 @@ 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* (owner) { const clientId = owner.clientId; @@ -189,7 +183,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { 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) => { @@ -199,7 +193,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); - const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( + const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( "PreviewAutomationBroker.clearOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { @@ -217,7 +211,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); - const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( + const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( "PreviewAutomationBroker.respond", )(function* (response) { const pending = yield* SynchronizedRef.modify(state, (current) => { @@ -243,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()) @@ -317,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/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8fa3a3668bf..159932c4bdc 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -28,33 +28,30 @@ import { 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; @@ -127,7 +124,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 +181,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 +249,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 +293,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 +303,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 +359,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.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.ts b/apps/server/src/process/externalLauncher.ts index 0b40acef5c0..e8cfce0e96a 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -22,7 +22,8 @@ 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 @@ -282,30 +283,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 @@ -397,7 +391,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -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 +404,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 +418,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..2c9d9f95038 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -11,14 +11,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; @@ -68,15 +61,16 @@ function makeSpawner( } 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 +106,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"], @@ -175,7 +169,7 @@ describe("runProcess", () => { maxOutputBytes: 128, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); }), ); @@ -200,7 +194,7 @@ describe("runProcess", () => { timeout: "2 seconds", }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessOutputLimitError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); }), ); @@ -285,7 +279,7 @@ describe("runProcess", () => { yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); - expect(error).toBeInstanceOf(ProcessTimeoutError); + expect(error).toBeInstanceOf(ProcessRunner.ProcessTimeoutError); }), ); @@ -324,7 +318,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..5f01fcc344b 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,42 +43,82 @@ 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, + args: Schema.Array(Schema.String), + cwd: 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; -}> {} +}): string => { + const command = [input.command, ...input.args].join(" "); + return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`; +}; -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, + 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, + 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, + }, +) { + override get message(): string { + return `Process ${formatProcessInvocation(this)} ${this.stream} exceeded ${this.maxBytes} bytes`; + } +} + +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 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 type ProcessRunError = | ProcessSpawnError @@ -86,13 +127,12 @@ export type ProcessRunError = | ProcessReadError | ProcessTimeoutError; -export interface ProcessRunnerShape { - readonly run: (input: ProcessRunInput) => Effect.Effect; -} - -export class ProcessRunner extends Context.Service()( - "t3/processRunner", -) {} +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; @@ -332,10 +372,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({ From 742dd7af0dcb54c32774f87fd354105726c7a9db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:11:33 -0700 Subject: [PATCH 034/142] [codex] Standardize Effect protocol adapter services (#3201) Co-authored-by: codex --- .../src/provider/Layers/CodexProvider.ts | 5 +- .../src/provider/Layers/CursorAdapter.ts | 8 +- .../src/provider/Layers/CursorProvider.ts | 11 +- .../server/src/provider/Layers/GrokAdapter.ts | 6 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 24 +- .../src/provider/acp/AcpNativeLogging.ts | 10 +- .../src/provider/acp/AcpSessionRuntime.ts | 224 +++++++--- .../provider/acp/CursorAcpCliProbe.test.ts | 8 +- .../src/provider/acp/CursorAcpSupport.ts | 25 +- .../server/src/provider/acp/GrokAcpSupport.ts | 25 +- packages/effect-acp/src/agent.ts | 353 +++++++-------- packages/effect-acp/src/client.ts | 416 +++++++++--------- packages/effect-acp/src/errors.ts | 6 +- .../effect-codex-app-server/src/client.ts | 84 ++-- 14 files changed, 661 insertions(+), 544 deletions(-) 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/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 12eb6054145..c7358edd55d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -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( 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/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index f2e286c589c..a2c44f0ac1b 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -10,7 +10,7 @@ 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)); @@ -20,9 +20,9 @@ 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({ @@ -350,7 +350,7 @@ describe("AcpSessionRuntime", () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); const requestLogPath = path.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); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 6146980e4fb..5814d935197 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -6,9 +6,9 @@ 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 formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { return { method: event.method, status: event.status, @@ -24,7 +24,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; @@ -57,7 +57,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" ); return { - requestLogger: (event: AcpSessionRequestLogEvent) => + requestLogger: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) => writeNativeAcpLog({ kind: "request", payload: formatRequestLogPayload(event), @@ -72,7 +72,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "protocol", payload: 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/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 67bcd0f4c6d..5cad53c3d12 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -27,189 +27,190 @@ export interface AcpAgentOptions { readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } -export interface AcpAgentShape { - readonly raw: { +export class AcpAgent extends Context.Service< + AcpAgent, + { + readonly raw: { + /** + * Stream of inbound ACP notifications observed on the connection. + */ + readonly notifications: Stream.Stream; + /** + * Sends a generic ACP extension request. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a generic ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly notify: (method: string, payload: unknown) => Effect.Effect; + }; + readonly client: { + /** + * Requests client permission for an operation. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission: ( + payload: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Requests structured user input from the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicit: ( + payload: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Requests file contents from the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile: ( + payload: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Writes a text file through the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile: ( + payload: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Creates a terminal on the client side. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal: ( + payload: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Sends a `session/update` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate: ( + payload: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Sends a `session/elicitation/complete` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete: ( + payload: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect; + /** + * Sends an ACP extension request to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends an ACP extension notification to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; + }; /** - * Stream of inbound ACP notifications observed on the connection. + * Registers a handler for `initialize`. + * @see https://agentclientprotocol.com/protocol/schema#initialize */ - readonly notifications: Stream.Stream; + readonly handleInitialize: ( + handler: ( + request: AcpSchema.InitializeRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a generic ACP extension request. - * @see https://agentclientprotocol.com/protocol/extensibility + * Registers a handler for `authenticate`. + * @see https://agentclientprotocol.com/protocol/schema#authenticate */ - readonly request: ( - method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends a generic ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly notify: (method: string, payload: unknown) => Effect.Effect; - }; - readonly client: { - /** - * Requests client permission for an operation. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly requestPermission: ( - payload: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect; - /** - * Requests structured user input from the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly elicit: ( - payload: AcpSchema.ElicitationRequest, - ) => Effect.Effect; - /** - * Requests file contents from the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly readTextFile: ( - payload: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect; - /** - * Writes a text file through the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly writeTextFile: ( - payload: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect; + readonly handleAuthenticate: ( + handler: ( + request: AcpSchema.AuthenticateRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLogout: ( + handler: ( + request: AcpSchema.LogoutRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCreateSession: ( + handler: ( + request: AcpSchema.NewSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLoadSession: ( + handler: ( + request: AcpSchema.LoadSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleListSessions: ( + handler: ( + request: AcpSchema.ListSessionsRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleForkSession: ( + handler: ( + request: AcpSchema.ForkSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleResumeSession: ( + handler: ( + request: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCloseSession: ( + handler: ( + request: AcpSchema.CloseSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionModel: ( + handler: ( + request: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionConfigOption: ( + handler: ( + request: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handlePrompt: ( + handler: ( + request: AcpSchema.PromptRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Creates a terminal on the client side. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create + * Registers a handler for `session/cancel`. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel */ - readonly createTerminal: ( - payload: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect; - /** - * Sends a `session/update` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly sessionUpdate: ( - payload: AcpSchema.SessionNotification, - ) => Effect.Effect; - /** - * Sends a `session/elicitation/complete` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly elicitationComplete: ( - payload: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect; - /** - * Sends an ACP extension request to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extRequest: ( + readonly handleCancel: ( + handler: ( + notification: AcpSchema.CancelNotification, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtRequest: ( method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends an ACP extension notification to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extNotification: ( + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtNotification: ( method: string, - payload: unknown, - ) => Effect.Effect; - }; - /** - * Registers a handler for `initialize`. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ - readonly handleInitialize: ( - handler: ( - request: AcpSchema.InitializeRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `authenticate`. - * @see https://agentclientprotocol.com/protocol/schema#authenticate - */ - readonly handleAuthenticate: ( - handler: ( - request: AcpSchema.AuthenticateRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleLogout: ( - handler: ( - request: AcpSchema.LogoutRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleCreateSession: ( - handler: ( - request: AcpSchema.NewSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleLoadSession: ( - handler: ( - request: AcpSchema.LoadSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleListSessions: ( - handler: ( - request: AcpSchema.ListSessionsRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleForkSession: ( - handler: ( - request: AcpSchema.ForkSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleResumeSession: ( - handler: ( - request: AcpSchema.ResumeSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleCloseSession: ( - handler: ( - request: AcpSchema.CloseSessionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleSetSessionModel: ( - handler: ( - request: AcpSchema.SetSessionModelRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleSetSessionConfigOption: ( - handler: ( - request: AcpSchema.SetSessionConfigOptionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handlePrompt: ( - handler: ( - request: AcpSchema.PromptRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/cancel`. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ - readonly handleCancel: ( - handler: (notification: AcpSchema.CancelNotification) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownExtRequest: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownExtNotification: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - readonly handleExtRequest: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; - readonly handleExtNotification: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; -} - -export class AcpAgent extends Context.Service()( - "effect-acp/agent/AcpAgent", -) {} + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + } +>()("effect-acp/agent/AcpAgent") {} interface AcpCoreAgentRequestHandlers { initialize?: ( @@ -255,7 +256,7 @@ const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNoti export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( stdio: Stdio.Stdio, options: AcpAgentOptions = {}, -): Effect.fn.Return { +): Effect.fn.Return { const coreHandlers: AcpCoreAgentRequestHandlers = {}; const cancelHandlers: Array< (notification: AcpSchema.CancelNotification) => Effect.Effect diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index c3a59c798c7..6f3d6a0c9f8 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -7,7 +7,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as AcpError from "./errors.ts"; import * as AcpProtocol from "./protocol.ts"; @@ -34,237 +34,236 @@ type AcpClientRaw = { readonly notify: (method: string, payload: unknown) => Effect.Effect; }; -export interface AcpClientShape { - readonly raw: AcpClientRaw; - readonly agent: { +export class AcpClient extends Context.Service< + AcpClient, + { + readonly raw: AcpClientRaw; + readonly agent: { + /** + * Initializes the ACP session and negotiates capabilities. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: ( + payload: AcpSchema.InitializeRequest, + ) => Effect.Effect; + /** + * Performs ACP authentication when the agent requires it. + * @see https://agentclientprotocol.com/protocol/schema#authenticate + */ + readonly authenticate: ( + payload: AcpSchema.AuthenticateRequest, + ) => Effect.Effect; + /** + * Logs out the current ACP identity. + * @see https://agentclientprotocol.com/protocol/schema#logout + */ + readonly logout: ( + payload: AcpSchema.LogoutRequest, + ) => Effect.Effect; + /** + * Starts a new ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/new + */ + readonly createSession: ( + payload: AcpSchema.NewSessionRequest, + ) => Effect.Effect; + /** + * Loads a previously saved ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/load + */ + readonly loadSession: ( + payload: AcpSchema.LoadSessionRequest, + ) => Effect.Effect; + /** + * Lists available ACP sessions. + * @see https://agentclientprotocol.com/protocol/schema#session/list + */ + readonly listSessions: ( + payload: AcpSchema.ListSessionsRequest, + ) => Effect.Effect; + /** + * Forks an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/fork + */ + readonly forkSession: ( + payload: AcpSchema.ForkSessionRequest, + ) => Effect.Effect; + /** + * Resumes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/resume + */ + readonly resumeSession: ( + payload: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect; + /** + * Closes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/close + */ + readonly closeSession: ( + payload: AcpSchema.CloseSessionRequest, + ) => Effect.Effect; + /** + * Selects the active model for a session. + * @see https://agentclientprotocol.com/protocol/schema#session/set_model + */ + readonly setSessionModel: ( + payload: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect; + /** + * Updates a session configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setSessionConfigOption: ( + payload: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect; + /** + * Sends a prompt turn to the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: AcpSchema.PromptRequest, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: ( + payload: AcpSchema.CancelNotification, + ) => Effect.Effect; + }; /** - * Initializes the ACP session and negotiates capabilities. - * @see https://agentclientprotocol.com/protocol/schema#initialize + * Registers a handler for `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission */ - readonly initialize: ( - payload: AcpSchema.InitializeRequest, - ) => Effect.Effect; + readonly handleRequestPermission: ( + handler: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Performs ACP authentication when the agent requires it. - * @see https://agentclientprotocol.com/protocol/schema#authenticate + * Registers a handler for `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation */ - readonly authenticate: ( - payload: AcpSchema.AuthenticateRequest, - ) => Effect.Effect; + readonly handleElicitation: ( + handler: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Logs out the current ACP identity. - * @see https://agentclientprotocol.com/protocol/schema#logout + * Registers a handler for `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file */ - readonly logout: ( - payload: AcpSchema.LogoutRequest, - ) => Effect.Effect; + readonly handleReadTextFile: ( + handler: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Starts a new ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/new + * Registers a handler for `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file */ - readonly createSession: ( - payload: AcpSchema.NewSessionRequest, - ) => Effect.Effect; + readonly handleWriteTextFile: ( + handler: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Loads a previously saved ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/load + * Registers a handler for `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create */ - readonly loadSession: ( - payload: AcpSchema.LoadSessionRequest, - ) => Effect.Effect; + readonly handleCreateTerminal: ( + handler: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Lists available ACP sessions. - * @see https://agentclientprotocol.com/protocol/schema#session/list + * Registers a handler for `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output */ - readonly listSessions: ( - payload: AcpSchema.ListSessionsRequest, - ) => Effect.Effect; + readonly handleTerminalOutput: ( + handler: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Forks an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/fork + * Registers a handler for `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit */ - readonly forkSession: ( - payload: AcpSchema.ForkSessionRequest, - ) => Effect.Effect; + readonly handleTerminalWaitForExit: ( + handler: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Resumes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/resume + * Registers a handler for `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill */ - readonly resumeSession: ( - payload: AcpSchema.ResumeSessionRequest, - ) => Effect.Effect; + readonly handleTerminalKill: ( + handler: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Closes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/close + * Registers a handler for `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release */ - readonly closeSession: ( - payload: AcpSchema.CloseSessionRequest, - ) => Effect.Effect; + readonly handleTerminalRelease: ( + handler: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Selects the active model for a session. - * @see https://agentclientprotocol.com/protocol/schema#session/set_model + * Registers a handler for `session/update`. + * @see https://agentclientprotocol.com/protocol/schema#session/update */ - readonly setSessionModel: ( - payload: AcpSchema.SetSessionModelRequest, - ) => Effect.Effect; + readonly handleSessionUpdate: ( + handler: ( + notification: AcpSchema.SessionNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Updates a session configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + * Registers a handler for `session/elicitation/complete`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete */ - readonly setSessionConfigOption: ( - payload: AcpSchema.SetSessionConfigOptionRequest, - ) => Effect.Effect; + readonly handleElicitationComplete: ( + handler: ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a prompt turn to the agent. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt + * Registers a fallback extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly prompt: ( - payload: AcpSchema.PromptRequest, - ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; /** - * Sends a real ACP `session/cancel` notification. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly cancel: ( - payload: AcpSchema.CancelNotification, - ) => Effect.Effect; - }; - /** - * Registers a handler for `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly handleRequestPermission: ( - handler: ( - request: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/elicitation`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly handleElicitation: ( - handler: ( - request: AcpSchema.ElicitationRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `fs/read_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly handleReadTextFile: ( - handler: ( - request: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `fs/write_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly handleWriteTextFile: ( - handler: ( - request: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/create`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly handleCreateTerminal: ( - handler: ( - request: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/output`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/output - */ - readonly handleTerminalOutput: ( - handler: ( - request: AcpSchema.TerminalOutputRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/wait_for_exit`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit - */ - readonly handleTerminalWaitForExit: ( - handler: ( - request: AcpSchema.WaitForTerminalExitRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/kill`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/kill - */ - readonly handleTerminalKill: ( - handler: ( - request: AcpSchema.KillTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `terminal/release`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/release - */ - readonly handleTerminalRelease: ( - handler: ( - request: AcpSchema.ReleaseTerminalRequest, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/update`. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly handleSessionUpdate: ( - handler: ( - notification: AcpSchema.SessionNotification, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a handler for `session/elicitation/complete`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly handleElicitationComplete: ( - handler: ( - notification: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a fallback extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtRequest: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a fallback extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleUnknownExtNotification: ( - handler: (method: string, params: unknown) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a typed extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtRequest: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; - /** - * Registers a typed extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly handleExtNotification: ( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, - ) => Effect.Effect; -} - -export class AcpClient extends Context.Service()( - "effect-acp/client/AcpClient", -) {} + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtRequest: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtNotification: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + } +>()("effect-acp/client/AcpClient") {} interface AcpCoreRequestHandlers { requestPermission?: ( @@ -310,7 +309,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( stdio: Stdio.Stdio, options: AcpClientOptions = {}, terminationError?: Effect.Effect, -): Effect.fn.Return { +): Effect.fn.Return { const coreHandlers: AcpCoreRequestHandlers = {}; const notificationHandlers: AcpNotificationHandlers = { sessionUpdate: { handlers: [], pending: [] }, @@ -559,6 +558,9 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); +export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer => + Layer.effect(AcpClient, make(stdio, options)); + export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 05e0b1b43ec..91668f841f9 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -45,7 +45,11 @@ export class AcpTransportError extends Schema.TaggedErrorClass()("AcpRequestError", { code: AcpSchema.ErrorCode, diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index f031b48d19c..c0cb5b1dc23 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -35,45 +35,46 @@ interface CodexAppServerClientRaw { readonly respondError: CodexProtocol.CodexAppServerPatchedProtocol["respondError"]; } -export interface CodexAppServerClientShape { - readonly raw: CodexAppServerClientRaw; - readonly request: ( - method: M, - payload: CodexRpc.ClientRequestParamsByMethod[M], - ) => Effect.Effect; - readonly notify: ( - method: M, - payload: CodexRpc.ClientNotificationParamsByMethod[M], - ) => Effect.Effect; - readonly handleServerRequest: ( - method: M, - handler: ( - payload: CodexRpc.ServerRequestParamsByMethod[M], - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleServerNotification: ( - method: M, - handler: ( - payload: CodexRpc.ServerNotificationParamsByMethod[M], - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownServerRequest: ( - handler: ( - method: string, - params: unknown, - ) => Effect.Effect, - ) => Effect.Effect; - readonly handleUnknownServerNotification: ( - handler: ( - method: string, - params: unknown, - ) => Effect.Effect, - ) => Effect.Effect; -} - export class CodexAppServerClient extends Context.Service< CodexAppServerClient, - CodexAppServerClientShape + { + readonly raw: CodexAppServerClientRaw; + readonly request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => Effect.Effect; + readonly notify: ( + method: M, + payload: CodexRpc.ClientNotificationParamsByMethod[M], + ) => Effect.Effect; + readonly handleServerRequest: ( + method: M, + handler: ( + payload: CodexRpc.ServerRequestParamsByMethod[M], + ) => Effect.Effect< + CodexRpc.ServerRequestResponsesByMethod[M], + CodexError.CodexAppServerError + >, + ) => Effect.Effect; + readonly handleServerNotification: ( + method: M, + handler: ( + payload: CodexRpc.ServerNotificationParamsByMethod[M], + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownServerRequest: ( + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownServerNotification: ( + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect; + } >()("effect-codex-app-server/client/CodexAppServerClient") {} type ServerRequestHandler = ( @@ -87,7 +88,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, -): Effect.fn.Return { +): Effect.fn.Return { const requestHandlers = new Map(); const notificationHandlers = new Map>(); let unknownRequestHandler: @@ -249,6 +250,11 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); +export const layer = ( + stdio: Stdio.Stdio, + options: CodexAppServerClientOptions = {}, +): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); + export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, From d5a72d5be105d16a95df6dc18ad08f0b0cdb7efb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:13:01 -0700 Subject: [PATCH 035/142] [codex] align relay agent activity Effect services (#3179) Co-authored-by: codex --- .../AgentActivityPublisher.test.ts | 31 ++--- .../agentActivity/AgentActivityPublisher.ts | 26 ++--- .../src/agentActivity/AgentActivityRows.ts | 42 +++---- infra/relay/src/agentActivity/ApnsClient.ts | 110 +++++++++--------- .../src/agentActivity/ApnsDeliveries.test.ts | 20 ++-- .../relay/src/agentActivity/ApnsDeliveries.ts | 67 ++++++----- .../src/agentActivity/ApnsDeliveryQueue.ts | 45 ++++--- .../src/agentActivity/DeliveryAttempts.ts | 29 +++-- infra/relay/src/agentActivity/Devices.ts | 33 +++--- .../relay/src/agentActivity/LiveActivities.ts | 73 ++++++------ .../agentActivity/MobileRegistrations.test.ts | 39 ++++--- .../src/agentActivity/MobileRegistrations.ts | 30 +++-- .../src/agentActivity/apnsDeliveryJobs.ts | 60 ++++++++-- 13 files changed, 320 insertions(+), 285 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 322ac77d896..9671f4984b2 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -41,8 +41,8 @@ function target(deviceId: string): LiveActivities.TargetRow { } function makeLiveActivities( - overrides: Partial = {}, -): LiveActivities.LiveActivitiesShape { + overrides: Partial = {}, +): LiveActivities.LiveActivities["Service"] { return { register: () => Effect.void, listTargets: () => Effect.succeed([]), @@ -55,8 +55,8 @@ function makeLiveActivities( } function makeAgentActivityRows( - overrides: Partial = {}, -): AgentActivityRows.AgentActivityRowsShape { + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRows["Service"] { return { upsert: () => Effect.void, remove: () => Effect.void, @@ -88,8 +88,8 @@ function makeEnvironmentLinks( } function makeApnsDeliveries( - overrides: Partial = {}, -): ApnsDeliveries.ApnsDeliveriesShape { + overrides: Partial = {}, +): ApnsDeliveries.ApnsDeliveries["Service"] { return { sendForTarget: () => Effect.succeed(null), sendPushNotificationForTarget: () => Effect.succeed(null), @@ -133,7 +133,8 @@ describe("AgentActivityPublisher", () => { remote_start_queued_at: null, remote_started_at: "1970-01-01T00:00:01.000Z", }; - const sent: Array[0]> = []; + const sent: Array[0]> = + []; const deliveryResult: RelayDeliveryResult = { deviceId: "device-1", kind: "live_activity_update", @@ -211,7 +212,8 @@ describe("AgentActivityPublisher", () => { readonly environmentId: string; readonly environmentPublicKey: string; }> = []; - const upserts: Array[0]> = []; + const upserts: Array[0]> = + []; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -302,9 +304,10 @@ describe("AgentActivityPublisher", () => { updatedAt: "1970-01-01T00:00:10.000Z", }; const sentAggregates: Array< - Parameters[0] + Parameters[0] > = []; - const removals: Array[0]> = []; + const removals: Array[0]> = + []; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -405,10 +408,10 @@ describe("AgentActivityPublisher", () => { headline: "Needs input", }; const liveAggregates: Array< - Parameters[0] + Parameters[0] > = []; const pushAggregates: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { @@ -517,10 +520,10 @@ describe("AgentActivityPublisher", () => { headline: "Needs approval", }; const liveAggregates: Array< - Parameters[0] + Parameters[0] > = []; const pushAggregates: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 0f5ddc32137..d33cc42cd8d 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -23,22 +23,20 @@ export type AgentActivityPublishError = | LiveActivities.LiveActivityTargetListPersistenceError | ApnsDeliveries.ApnsDeliveryError; -export interface AgentActivityPublisherShape { - readonly publish: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - readonly state: RelayAgentActivityState | null; - }) => Effect.Effect; - readonly replayForLiveActivityRegistration: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; -} - export class AgentActivityPublisher extends Context.Service< AgentActivityPublisher, - AgentActivityPublisherShape + { + readonly publish: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly state: RelayAgentActivityState | null; + }) => Effect.Effect; + readonly replayForLiveActivityRegistration: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + } >()("t3code-relay/agentActivity/AgentActivityPublisher") {} const make = Effect.gen(function* () { diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index a0695b8e7da..854facfc7c5 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -3,7 +3,7 @@ import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3too import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { cast } from "effect/Function"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -39,24 +39,26 @@ export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClas } } -export interface AgentActivityRowsShape { - readonly upsert: (input: { - readonly environmentPublicKey: string; - readonly state: RelayAgentActivityState; - }) => Effect.Effect; - readonly remove: (input: { - readonly environmentId: string; - readonly environmentPublicKey: string; - readonly threadId: string; - }) => Effect.Effect; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect, AgentActivityRowListPersistenceError>; -} - -export class AgentActivityRows extends Context.Service()( - "t3code-relay/agentActivity/AgentActivityRows", -) {} +export class AgentActivityRows extends Context.Service< + AgentActivityRows, + { + readonly upsert: (input: { + readonly environmentPublicKey: string; + readonly state: RelayAgentActivityState; + }) => Effect.Effect; + readonly remove: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + AgentActivityRowListPersistenceError + >; + } +>()("t3code-relay/agentActivity/AgentActivityRows") {} const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); @@ -82,7 +84,7 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( Effect.flatMap(decodeJsonString), - Effect.map(cast), + Effect.map(Function.cast), ); yield* db .insert(relayAgentActivityRows) diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 90c0fe7dc84..61bfd69bc96 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -8,8 +8,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; -import * as RelayConfiguration from "../Config.ts"; +import * as Headers from "effect/unstable/http/Headers"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type * as RelayConfiguration from "../Config.ts"; import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; @@ -231,29 +233,28 @@ function apnsReasonFromBody(body: string): string | undefined { }); } -export interface ApnsClientShape { - readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; - readonly makePushNotificationRequest: typeof makePushNotificationRequest; - readonly sendLiveActivityRequest: (input: { - readonly credentials: RelayConfiguration.ApnsCredentials; - readonly request: ApnsLiveActivityRequest; - readonly issuedAtUnixSeconds: number; - }) => Effect.Effect; - readonly sendPushNotificationRequest: (input: { - readonly credentials: RelayConfiguration.ApnsCredentials; - readonly request: ApnsPushNotificationRequest; - readonly issuedAtUnixSeconds: number; - }) => Effect.Effect; -} - -export class ApnsClient extends Context.Service()( - "t3code-relay/agentActivity/ApnsClient", -) {} +export class ApnsClient extends Context.Service< + ApnsClient, + { + readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; + readonly makePushNotificationRequest: typeof makePushNotificationRequest; + readonly sendLiveActivityRequest: (input: { + readonly credentials: RelayConfiguration.ApnsCredentials; + readonly request: ApnsLiveActivityRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; + readonly sendPushNotificationRequest: (input: { + readonly credentials: RelayConfiguration.ApnsCredentials; + readonly request: ApnsPushNotificationRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsClient") {} const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; - const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn( + const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( "relay.apns.send_live_activity_request", )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); @@ -288,40 +289,41 @@ const make = Effect.gen(function* () { }; }); - const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn( - "relay.apns.send_push_notification_request", - )(function* (input) { - yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); - const jwt = yield* makeApnsJwt({ - ...input.credentials, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, + const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = + Effect.fn("relay.apns.send_push_notification_request")(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); + const jwt = yield* makeApnsJwt({ + ...input.credentials, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + }); + const host = + input.credentials.environment === "production" + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com"; + const response = yield* HttpClientRequest.post( + `${host}/3/device/${input.request.token}`, + ).pipe( + HttpClientRequest.setHeaders({ + authorization: `bearer ${jwt}`, + "apns-priority": input.request.priority, + "apns-push-type": "alert", + "apns-topic": input.credentials.bundleId, + }), + HttpClientRequest.bodyJson(input.request.payload), + Effect.flatMap(httpClient.execute), + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const responseText = yield* response.text.pipe( + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const reason = apnsReasonFromBody(responseText); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + ...(reason === undefined ? {} : { reason }), + apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), + }; }); - const host = - input.credentials.environment === "production" - ? "https://api.push.apple.com" - : "https://api.sandbox.push.apple.com"; - const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( - HttpClientRequest.setHeaders({ - authorization: `bearer ${jwt}`, - "apns-priority": input.request.priority, - "apns-push-type": "alert", - "apns-topic": input.credentials.bundleId, - }), - HttpClientRequest.bodyJson(input.request.payload), - Effect.flatMap(httpClient.execute), - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), - ); - const responseText = yield* response.text.pipe( - Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), - ); - const reason = apnsReasonFromBody(responseText); - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - ...(reason === undefined ? {} : { reason }), - apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), - }; - }); return ApnsClient.of({ makeLiveActivityRequest, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 81de6d32687..1497b6a73f4 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -141,16 +141,16 @@ function makeLayer(input: { readonly sourceJobClaims?: ReadonlyMap; readonly queuedJobs?: Array; readonly queuedStarts?: Array< - Parameters[0] + Parameters[0] >; readonly clearedStarts?: Array< - Parameters[0] + Parameters[0] >; readonly markedDeliveries?: Array< - Parameters[0] + Parameters[0] >; readonly invalidatedTokens?: Array< - Parameters[0] + Parameters[0] >; readonly currentTargets?: ReadonlyArray; readonly config?: RelayConfiguration.RelayConfiguration["Service"]; @@ -227,10 +227,10 @@ describe("ApnsDeliveries", () => { const attempts: Array = []; const queuedJobs: Array = []; const queuedStarts: Array< - Parameters[0] + Parameters[0] > = []; const markedDeliveries: Array< - Parameters[0] + Parameters[0] > = []; return Effect.gen(function* () { @@ -933,7 +933,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "push_notification", @@ -1000,7 +1000,7 @@ describe("ApnsDeliveries", () => { it.effect("clears queued start state when a start job fails in APNs", () => { const attempts: Array = []; const clearedStarts: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_start", @@ -1035,7 +1035,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_start", @@ -1082,7 +1082,7 @@ describe("ApnsDeliveries", () => { it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => { const attempts: Array = []; const invalidatedTokens: Array< - Parameters[0] + Parameters[0] > = []; const payload = makeApnsDeliveryJobPayload({ kind: "live_activity_update", diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c1dba1467fa..d70808144fc 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -356,7 +356,7 @@ export type SendLiveActivityDeliveryInput = }); function makeLiveActivityDeliveryRequest( - apns: Apns.ApnsClientShape, + apns: Apns.ApnsClient["Service"], input: SendLiveActivityDeliveryInput, now: DateTime.DateTime, ) { @@ -391,33 +391,32 @@ function makeLiveActivityDeliveryRequest( } } -export interface ApnsDeliveriesShape { - readonly sendForTarget: (input: { - readonly target: LiveActivities.TargetRow; - readonly aggregate: RelayAgentActivityAggregateState | null; - readonly nowMs: number; - }) => Effect.Effect; - readonly sendPushNotificationForTarget: (input: { - readonly target: LiveActivities.TargetRow; - readonly aggregate: RelayAgentActivityAggregateState | null; - }) => Effect.Effect; - readonly sendLiveActivity: ( - input: SendLiveActivityDeliveryInput, - ) => Effect.Effect; - readonly processSignedJob: ( - body: unknown, - ) => Effect.Effect; - readonly sendPushNotification: (input: { - readonly target: LiveActivityDeliveryTarget; - readonly token: string; - readonly sourceJobId?: string | null; - readonly notification: ApnsNotificationPayload; - }) => Effect.Effect; -} - -export class ApnsDeliveries extends Context.Service()( - "t3code-relay/agentActivity/ApnsDeliveries", -) {} +export class ApnsDeliveries extends Context.Service< + ApnsDeliveries, + { + readonly sendForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; + }) => Effect.Effect; + readonly sendPushNotificationForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + }) => Effect.Effect; + readonly sendLiveActivity: ( + input: SendLiveActivityDeliveryInput, + ) => Effect.Effect; + readonly processSignedJob: ( + body: unknown, + ) => Effect.Effect; + readonly sendPushNotification: (input: { + readonly target: LiveActivityDeliveryTarget; + readonly token: string; + readonly sourceJobId?: string | null; + readonly notification: ApnsNotificationPayload; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsDeliveries") {} const make = Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; @@ -442,7 +441,7 @@ const make = Effect.gen(function* () { ); }); - const sendLiveActivity: ApnsDeliveriesShape["sendLiveActivity"] = Effect.fn( + const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn( "relay.apns_deliveries.send_live_activity", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -550,7 +549,7 @@ const make = Effect.gen(function* () { }; }); - const sendPushNotification: ApnsDeliveriesShape["sendPushNotification"] = Effect.fn( + const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn( "relay.apns_deliveries.send_push_notification", )(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -654,14 +653,14 @@ const make = Effect.gen(function* () { }; }); - const processSignedJob: ApnsDeliveriesShape["processSignedJob"] = Effect.fn( + const processSignedJob: ApnsDeliveries["Service"]["processSignedJob"] = Effect.fn( "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( Effect.mapError( () => new ApnsDeliveryJobInvalid({ - message: "Invalid APNs delivery queue job.", + reason: "invalid-queue-payload", }), ), ); @@ -686,7 +685,7 @@ const make = Effect.gen(function* () { if (payload.aggregate === null) { return Effect.fail( new ApnsDeliveryJobInvalid({ - message: "Live Activity start/update jobs require an aggregate.", + reason: "missing-live-activity-aggregate", }), ); } @@ -715,7 +714,7 @@ const make = Effect.gen(function* () { if (payload.notification === null) { return Effect.fail( new ApnsDeliveryJobInvalid({ - message: "Push notification jobs require a notification payload.", + reason: "missing-push-notification", }), ); } diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 3582e236b4d..33c21cf0d54 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -33,34 +33,31 @@ export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass Effect.Effect; -} - export class ApnsDeliveryQueueSender extends Context.Service< ApnsDeliveryQueueSender, - ApnsDeliveryQueueSenderShape + { + readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; + } >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} -export interface ApnsDeliveryQueueShape { - readonly enqueueLiveActivity: (input: { - readonly kind: ApnsDeliveryJobPayload["kind"]; - readonly userId: string; - readonly deviceId: string; - readonly token: string; - readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; - }) => Effect.Effect; - readonly enqueuePushNotification: (input: { - readonly userId: string; - readonly deviceId: string; - readonly token: string; - readonly notification: NonNullable; - }) => Effect.Effect; -} - -export class ApnsDeliveryQueue extends Context.Service()( - "t3code-relay/agentActivity/ApnsDeliveryQueue", -) {} +export class ApnsDeliveryQueue extends Context.Service< + ApnsDeliveryQueue, + { + readonly enqueueLiveActivity: (input: { + readonly kind: ApnsDeliveryJobPayload["kind"]; + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; + }) => Effect.Effect; + readonly enqueuePushNotification: (input: { + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly notification: NonNullable; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsDeliveryQueue") {} const make = Effect.gen(function* () { const sender = yield* ApnsDeliveryQueueSender; diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 6eb9b93c388..931837818b6 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -43,21 +43,20 @@ export interface DeliveryAttemptCompletionInput { export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight"; -export interface DeliveryAttemptsShape { - readonly record: ( - input: DeliveryAttemptInput, - ) => Effect.Effect; - readonly claimSourceJob: ( - input: DeliveryAttemptInput & { readonly sourceJobId: string }, - ) => Effect.Effect; - readonly completeSourceJob: ( - input: DeliveryAttemptCompletionInput, - ) => Effect.Effect; -} - -export class DeliveryAttempts extends Context.Service()( - "t3code-relay/agentActivity/DeliveryAttempts", -) {} +export class DeliveryAttempts extends Context.Service< + DeliveryAttempts, + { + readonly record: ( + input: DeliveryAttemptInput, + ) => Effect.Effect; + readonly claimSourceJob: ( + input: DeliveryAttemptInput & { readonly sourceJobId: string }, + ) => Effect.Effect; + readonly completeSourceJob: ( + input: DeliveryAttemptCompletionInput, + ) => Effect.Effect; + } +>()("t3code-relay/agentActivity/DeliveryAttempts") {} const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 108735f27ae..51a9bd53d64 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -40,23 +40,22 @@ export class DeviceListPersistenceError extends Schema.TaggedErrorClass Effect.Effect; - readonly unregister: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly listForUser: (input: { - readonly userId: string; - }) => Effect.Effect, DeviceListPersistenceError>; -} - -export class Devices extends Context.Service()( - "t3code-relay/agentActivity/Devices", -) {} +export class Devices extends Context.Service< + Devices, + { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregister: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, DeviceListPersistenceError>; + } +>()("t3code-relay/agentActivity/Devices") {} const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 988dd6988b2..9ee1274b935 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -7,7 +7,7 @@ import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSch import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { cast } from "effect/Function"; +import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { and, eq, sql } from "drizzle-orm"; @@ -64,41 +64,40 @@ export interface LiveActivityRow { export type TargetRow = DeviceRow & LiveActivityRow; -export interface LiveActivitiesShape { - readonly register: (input: { - readonly userId: string; - readonly registration: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly listTargets: (input: { - readonly userId: string; - }) => Effect.Effect, LiveActivityTargetListPersistenceError>; - readonly markDelivery: (input: { - readonly userId: string; - readonly deviceId: string; - readonly kind: RelayDeliveryKind; - readonly aggregate: RelayAgentActivityAggregateState | null; - readonly deliveredAt: string; - }) => Effect.Effect; - readonly markStartQueued: (input: { - readonly userId: string; - readonly deviceId: string; - readonly queuedAt: string; - }) => Effect.Effect; - readonly clearStartQueued: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly invalidateDeliveryToken: (input: { - readonly userId: string; - readonly deviceId: string; - readonly kind: RelayDeliveryKind; - readonly invalidatedAt: string; - }) => Effect.Effect; -} - -export class LiveActivities extends Context.Service()( - "t3code-relay/agentActivity/LiveActivities", -) {} +export class LiveActivities extends Context.Service< + LiveActivities, + { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly listTargets: (input: { + readonly userId: string; + }) => Effect.Effect, LiveActivityTargetListPersistenceError>; + readonly markDelivery: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly deliveredAt: string; + }) => Effect.Effect; + readonly markStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + readonly queuedAt: string; + }) => Effect.Effect; + readonly clearStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly invalidateDeliveryToken: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly invalidatedAt: string; + }) => Effect.Effect; + } +>()("t3code-relay/agentActivity/LiveActivities") {} const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); @@ -223,7 +222,7 @@ const make = Effect.gen(function* () { ? null : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( Effect.flatMap(decodeJsonString), - Effect.map(cast), + Effect.map(Function.cast), ); yield* db diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 8d8e6f21461..eed330dd589 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -38,7 +38,9 @@ const device: RelayDeviceRegistrationRequest = { }, }; -function makeDevices(overrides: Partial = {}): Devices.DevicesShape { +function makeDevices( + overrides: Partial = {}, +): Devices.Devices["Service"] { return { register: () => Effect.void, unregister: () => Effect.void, @@ -48,8 +50,8 @@ function makeDevices(overrides: Partial = {}): Devices.Dev } function makeLiveActivities( - overrides: Partial = {}, -): LiveActivities.LiveActivitiesShape { + overrides: Partial = {}, +): LiveActivities.LiveActivities["Service"] { return { register: () => Effect.void, listTargets: () => Effect.succeed([]), @@ -62,8 +64,8 @@ function makeLiveActivities( } function makeAgentActivityRows( - overrides: Partial = {}, -): AgentActivityRows.AgentActivityRowsShape { + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRows["Service"] { return { upsert: () => Effect.void, remove: () => Effect.void, @@ -108,8 +110,8 @@ function makeEnvironmentLinks( } function makeDeliveryAttempts( - overrides: Partial = {}, -): DeliveryAttempts.DeliveryAttemptsShape { + overrides: Partial = {}, +): DeliveryAttempts.DeliveryAttempts["Service"] { return { record: () => Effect.void, claimSourceJob: () => Effect.succeed("claimed"), @@ -138,8 +140,8 @@ const config = RelayConfiguration.RelayConfiguration.of({ }); function makeRegistrationReplayLayer(input: { - readonly devices: Devices.DevicesShape; - readonly liveActivities: LiveActivities.LiveActivitiesShape; + readonly devices: Devices.Devices["Service"]; + readonly liveActivities: LiveActivities.LiveActivities["Service"]; readonly queuedJobs: Array; }) { return MobileRegistrations.layer.pipe( @@ -167,8 +169,8 @@ function makeRegistrationReplayLayer(input: { } function makeAgentActivityPublisher( - overrides: Partial = {}, -): AgentActivityPublisher.AgentActivityPublisherShape { + overrides: Partial = {}, +): AgentActivityPublisher.AgentActivityPublisher["Service"] { return { publish: () => Effect.succeed({ ok: true, deliveries: [] }), replayForLiveActivityRegistration: () => Effect.succeed(null), @@ -178,10 +180,10 @@ function makeAgentActivityPublisher( describe("MobileRegistrations", () => { it.effect("registers devices through the device persistence service", () => { - let registered: Parameters[0] | null = null; + let registered: Parameters[0] | null = null; let replayed: | Parameters< - AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] >[0] | null = null; @@ -263,7 +265,7 @@ describe("MobileRegistrations", () => { }); it.effect("unregisters the current user's device", () => { - let unregistered: Parameters[0] | null = null; + let unregistered: Parameters[0] | null = null; return Effect.gen(function* () { const result = yield* Effect.gen(function* () { @@ -310,10 +312,11 @@ describe("MobileRegistrations", () => { deviceId: "device-1" as const, activityPushToken: "activity-token" as const, }; - let registered: Parameters[0] | null = null; + let registered: Parameters[0] | null = + null; let replayed: | Parameters< - AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] >[0] | null = null; @@ -372,9 +375,9 @@ describe("MobileRegistrations", () => { () => { const queuedJobs: Array = []; const queuedStarts: Array< - Parameters[0] + Parameters[0] > = []; - const registeredDevices: Array[0]> = []; + const registeredDevices: Array[0]> = []; const devices = makeDevices({ register: (input) => Effect.sync(() => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index d9c013232a3..b44d24dfa5d 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -15,24 +15,22 @@ export type MobileRegistrationError = | Devices.DeviceUnregistrationPersistenceError | LiveActivities.LiveActivityRegistrationPersistenceError; -export interface MobileRegistrationsShape { - readonly registerDevice: (input: { - readonly userId: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; - readonly registerLiveActivity: (input: { - readonly userId: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; - readonly unregisterDevice: (input: { - readonly userId: string; - readonly deviceId: string; - }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; -} - export class MobileRegistrations extends Context.Service< MobileRegistrations, - MobileRegistrationsShape + { + readonly registerDevice: (input: { + readonly userId: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly registerLiveActivity: (input: { + readonly userId: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly unregisterDevice: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + } >()("t3code-relay/agentActivity/MobileRegistrations") {} const make = Effect.gen(function* () { diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index d509baa9168..8de33752a9a 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -52,9 +52,45 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; export class ApnsDeliveryJobInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobInvalid", { - message: Schema.String, + reason: Schema.Literals([ + "invalid-queue-payload", + "missing-live-activity-aggregate", + "unexpected-live-activity-notification", + "missing-push-notification", + "unexpected-push-notification-aggregate", + "invalid-created-at", + "invalid-expires-at", + "invalid-time-window", + "time-window-too-long", + "invalid-signature", + ]), }, -) {} +) { + override get message(): string { + switch (this.reason) { + case "invalid-queue-payload": + return "Invalid APNs delivery queue job."; + case "missing-live-activity-aggregate": + return "Live Activity start/update jobs require an aggregate."; + case "unexpected-live-activity-notification": + return "Live Activity jobs must not carry push notification payloads."; + case "missing-push-notification": + return "Push notification jobs require a notification payload."; + case "unexpected-push-notification-aggregate": + return "Push notification jobs must not carry aggregate state."; + case "invalid-created-at": + return "Invalid APNs delivery job creation time."; + case "invalid-expires-at": + return "Invalid APNs delivery job expiry."; + case "invalid-time-window": + return "Invalid APNs delivery job time window."; + case "time-window-too-long": + return "APNs delivery job time window is too long."; + case "invalid-signature": + return "Invalid APNs delivery job signature."; + } + } +} export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpired", @@ -106,31 +142,31 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI case "live_activity_update": if (payload.aggregate === null) { return new ApnsDeliveryJobInvalid({ - message: "Live Activity start/update jobs require an aggregate.", + reason: "missing-live-activity-aggregate", }); } if (payload.notification !== null) { return new ApnsDeliveryJobInvalid({ - message: "Live Activity jobs must not carry push notification payloads.", + reason: "unexpected-live-activity-notification", }); } return null; case "live_activity_end": if (payload.notification !== null) { return new ApnsDeliveryJobInvalid({ - message: "Live Activity jobs must not carry push notification payloads.", + reason: "unexpected-live-activity-notification", }); } return null; case "push_notification": if (payload.notification === null) { return new ApnsDeliveryJobInvalid({ - message: "Push notification jobs require a notification payload.", + reason: "missing-push-notification", }); } if (payload.aggregate !== null) { return new ApnsDeliveryJobInvalid({ - message: "Push notification jobs must not carry aggregate state.", + reason: "unexpected-push-notification-aggregate", }); } return null; @@ -177,19 +213,19 @@ export function verifySignedApnsDeliveryJob(input: { } const createdAt = DateTime.make(input.job.payload.createdAt); if (Option.isNone(createdAt)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job creation time." }); + return new ApnsDeliveryJobInvalid({ reason: "invalid-created-at" }); } const expiresAt = DateTime.make(input.job.payload.expiresAt); if (Option.isNone(expiresAt)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job expiry." }); + return new ApnsDeliveryJobInvalid({ reason: "invalid-expires-at" }); } const createdAtMs = createdAt.value.epochMilliseconds; const expiresAtMs = expiresAt.value.epochMilliseconds; if (expiresAtMs <= createdAtMs) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job time window." }); + return new ApnsDeliveryJobInvalid({ reason: "invalid-time-window" }); } if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobInvalid({ message: "APNs delivery job time window is too long." }); + return new ApnsDeliveryJobInvalid({ reason: "time-window-too-long" }); } if (expiresAtMs <= input.nowMs) { return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); @@ -199,7 +235,7 @@ export function verifySignedApnsDeliveryJob(input: { payload: input.job.payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job signature." }); + return new ApnsDeliveryJobInvalid({ reason: "invalid-signature" }); } return input.job.payload; } From df1540f3ac8fd70877a83e91bd0be182ebf5022c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:14:18 -0700 Subject: [PATCH 036/142] [codex] align persistence Effect service modules (#3184) Co-authored-by: codex --- .../providerService.integration.test.ts | 4 +- apps/server/src/auth/PairingGrantStore.ts | 7 +- apps/server/src/auth/SessionStore.test.ts | 2 +- apps/server/src/auth/SessionStore.ts | 7 +- .../{Layers => }/AuthPairingLinks.ts | 112 +++++-- .../persistence/{Layers => }/AuthSessions.ts | 133 ++++++-- .../Layers/ProviderSessionRuntime.ts | 210 +------------ .../src/persistence/NodeSqliteClient.ts | 28 +- .../src/persistence/ProviderSessionRuntime.ts | 288 ++++++++++++++++++ .../persistence/Services/AuthPairingLinks.ts | 82 ----- .../src/persistence/Services/AuthSessions.ts | 100 ------ .../Services/ProviderSessionRuntime.ts | 92 ------ .../provider/Layers/ProviderService.test.ts | 33 +- .../Layers/ProviderSessionDirectory.test.ts | 15 +- .../Layers/ProviderSessionDirectory.ts | 7 +- .../Layers/ProviderSessionReaper.test.ts | 31 +- apps/server/src/server.ts | 4 +- 17 files changed, 546 insertions(+), 609 deletions(-) rename apps/server/src/persistence/{Layers => }/AuthPairingLinks.ts (60%) rename apps/server/src/persistence/{Layers => }/AuthSessions.ts (65%) create mode 100644 apps/server/src/persistence/ProviderSessionRuntime.ts delete mode 100644 apps/server/src/persistence/Services/AuthPairingLinks.ts delete mode 100644 apps/server/src/persistence/Services/AuthSessions.ts delete mode 100644 apps/server/src/persistence/Services/ProviderSessionRuntime.ts 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/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index e97696fbadd..c655a0f36b6 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -18,8 +18,7 @@ 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 AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; @@ -126,7 +125,7 @@ const internalBootstrapCredentialError = (message: string, cause: unknown) => export const make = Effect.fn("makePairingGrantStore")(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig; - const pairingLinks = yield* AuthPairingLinkRepository; + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); const generatePairingToken = Effect.gen(function* () { @@ -417,5 +416,5 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); export const layer = Layer.effect(PairingGrantStore, make()).pipe( - Layer.provideMerge(AuthPairingLinkRepositoryLive), + Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 967766a7a4e..130222408a6 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -8,7 +8,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; -import * as AuthSessions from "../persistence/Services/AuthSessions.ts"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 8de145ca338..e1064c27904 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -22,8 +22,7 @@ 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 AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, @@ -195,7 +194,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* 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(); @@ -640,5 +639,5 @@ export const make = Effect.fn("makeSessionStore")(function* () { }); export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessionRepositoryLive), + Layer.provideMerge(AuthSessions.layer), ); diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts similarity index 60% rename from apps/server/src/persistence/Layers/AuthPairingLinks.ts rename to apps/server/src/persistence/AuthPairingLinks.ts index 9d2760d1449..add90f04803 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -1,24 +1,91 @@ -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 { + type AuthPairingLinkRepositoryError, toPersistenceDecodeError, toPersistenceSqlError, - type AuthPairingLinkRepositoryError, -} from "../Errors.ts"; -import { - AuthPairingLinkRecord, +} 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 => @@ -27,7 +94,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(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 65% rename from apps/server/src/persistence/Layers/AuthSessions.ts rename to apps/server/src/persistence/AuthSessions.ts index ab84e3fa041..e3e8a19f5d0 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 { + AuthClientMetadataDeviceType, + AuthEnvironmentScopes, + AuthSessionId, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; import { + type AuthSessionRepositoryError, toPersistenceDecodeError, toPersistenceSqlError, - type AuthSessionRepositoryError, -} from "../Errors.ts"; -import { - AuthSessionRecord, +} 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, @@ -68,7 +150,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(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/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..fd49edf0529 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -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; @@ -251,25 +246,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..6bbbfbd4e19 --- /dev/null +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -0,0 +1,288 @@ +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 { + type ProviderSessionRuntimeRepositoryError, + toPersistenceDecodeError, + toPersistenceSqlError, +} 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) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(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( + toPersistenceDecodeError( + "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + ), + ), + 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( + toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + ), + ), + { concurrency: "unbounded" }, + ), + ), + ); + + const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteRuntimeByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + ), + ); + + 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/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6a72bf69941..8581a11213b 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -53,8 +53,7 @@ import { makeProviderServiceLive } from "./ProviderService.ts"; import { NoOpProviderEventLoggers, 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, @@ -282,7 +281,7 @@ function makeProviderServiceLayer() { }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -327,7 +326,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => [CODEX_DRIVER]: codex.adapter, }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -379,7 +378,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () : registryBase.getInstanceInfo(instanceId), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -453,7 +452,7 @@ it.effect( }, }, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -517,7 +516,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance ), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -557,7 +556,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)); @@ -612,7 +611,7 @@ 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)); @@ -643,7 +642,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( 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"), }); @@ -671,7 +670,7 @@ it.effect( const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -717,7 +716,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, }); @@ -909,7 +908,7 @@ 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 runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { provider: ProviderDriverKind.make("codex"), @@ -1179,7 +1178,7 @@ 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 runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = asThreadId("thread-runtime-status"); const session = yield* provider.startSession(threadId, { @@ -1226,7 +1225,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1316,7 +1315,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1761,7 +1760,7 @@ 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 runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index f9793ca9d1f..c5d60a69a22 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -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({ 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/server.ts b/apps/server/src/server.ts index 373dc61bad8..f1e900c0b5a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,7 +19,7 @@ import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/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 * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; @@ -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 From 93fd9721086d4325794830a29a7df0592d120683 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:17:56 -0700 Subject: [PATCH 037/142] [codex] Refactor desktop window and update Effect services (#3202) Co-authored-by: codex --- apps/desktop/src/updates/DesktopUpdates.ts | 59 ++++++++++--------- .../src/window/DesktopApplicationMenu.test.ts | 4 +- .../src/window/DesktopApplicationMenu.ts | 10 ++-- apps/desktop/src/window/DesktopWindow.ts | 29 +++++---- 4 files changed, 50 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 8a232788590..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, @@ -185,7 +186,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const backendManager = yield* DesktopBackendManager.DesktopBackendManager; const desktopState = yield* DesktopState.DesktopState; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 62d619fe18b..f3444c629f7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -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, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 2d41fa9db86..04b9c833e44 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -14,13 +14,11 @@ 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 DesktopApplicationMenu extends Context.Service< DesktopApplicationMenu, - DesktopApplicationMenuShape + { + readonly configure: Effect.Effect; + } >()("@t3tools/desktop/window/DesktopApplicationMenu") {} type DesktopApplicationMenuRuntimeServices = @@ -94,7 +92,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; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e911d4ff766..1822bb0c98e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -42,20 +42,19 @@ export type DesktopWindowError = | 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"); @@ -143,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; From 01492ebd3b624e544e6375c12540b741c65256b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:18:55 -0700 Subject: [PATCH 038/142] [codex] Refactor agent awareness relay service (#3197) Co-authored-by: codex --- apps/server/src/relay/AgentAwarenessRelay.ts | 48 ++++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 91babdce4eb..8528b4b0c8e 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/Services/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 { @@ -265,11 +263,11 @@ 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); @@ -417,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", { @@ -480,7 +478,7 @@ const make = Effect.gen(function* () { 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, publishEnabled] = yield* Effect.all([ readRelayConfig.pipe(Effect.orElseSucceed(() => null)), @@ -536,10 +534,10 @@ const make = Effect.gen(function* () { }, ); - return { + return AgentAwarenessRelay.of({ publishThread, start, - } satisfies AgentAwarenessRelayShape; + }); }); export const layer = Layer.effect(AgentAwarenessRelay, make); From 4ee719a094d4c4f30fab88c1f6969483890e223e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:21:15 -0700 Subject: [PATCH 039/142] [codex] refactor server cloud Effect services (#3183) Co-authored-by: codex --- apps/server/src/cloud/CliTokenManager.ts | 102 ++++++++++++------ .../src/cloud/ManagedEndpointRuntime.test.ts | 98 +++++++++-------- .../src/cloud/ManagedEndpointRuntime.ts | 57 +++++----- apps/server/src/cloud/http.test.ts | 23 ++-- apps/server/src/cloud/http.ts | 40 +++---- 5 files changed, 180 insertions(+), 140 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 88a61f5df74..f2ad5e621ec 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -6,7 +6,6 @@ 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,7 +136,7 @@ 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); @@ -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 02c4b0d09ac..e0d5924fcc2 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -4,16 +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 { - classifyRelayClientOutput, - makeCloudManagedEndpointRuntime, -} from "./ManagedEndpointRuntime.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const relayClientAvailableLayer = Layer.succeed( RelayClient.RelayClient, @@ -29,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; @@ -62,16 +82,20 @@ function makeHandle(input: { describe("CloudManagedEndpointRuntime", () => { it("classifies Cloudflare connection and warning output", () => { expect( - classifyRelayClientOutput( + ManagedEndpointRuntime.classifyRelayClientOutput( "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", ), ).toBe("connected"); expect( - classifyRelayClientOutput("2026-06-17T02:00:00Z ERR Failed to serve tunnel connection"), + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z ERR Failed to serve tunnel connection", + ), ).toBe("warning"); - expect(classifyRelayClientOutput("2026-06-17T02:00:00Z INF Starting metrics server")).toBe( - "debug", - ); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z INF Starting metrics server", + ), + ).toBe("debug"); }); it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => @@ -97,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", @@ -154,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", @@ -193,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", @@ -240,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", @@ -282,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({ @@ -322,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", @@ -344,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 f2eedaf0c6d..a1d7112a929 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -10,7 +10,8 @@ import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; 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 * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; @@ -28,17 +29,6 @@ const readRuntimeConfig = Effect.gen(function* () { 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"; @@ -62,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; @@ -97,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); @@ -301,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/http.test.ts b/apps/server/src/cloud/http.test.ts index 78285eb7dcd..3a8586f150a 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -9,13 +9,10 @@ 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/Services/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => @@ -32,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, @@ -151,21 +148,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 773891124c5..86716b69a35 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -55,14 +55,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; -import { - ServerEnvironment, - type ServerEnvironmentShape, -} from "../environment/Services/ServerEnvironment.ts"; -import { - CloudManagedEndpointRuntime, - type CloudManagedEndpointRuntimeShape, -} from "./ManagedEndpointRuntime.ts"; +import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; +import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, @@ -103,6 +97,9 @@ const failEnvironmentCloudInternalError = Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), ); +const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => + failEnvironmentCloudInternalError(error.message)(error.cause); + const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( () => @@ -121,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; }) { @@ -208,7 +205,7 @@ 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( @@ -237,7 +234,7 @@ function validateLinkedCloudUser(input: { } function readInstalledCloudUserId( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ): Effect.Effect { return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( @@ -335,19 +332,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, @@ -595,8 +592,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }); }, Effect.catchTags({ - CloudCliTokenManagerError: (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + CloudCliCredentialRemovalError: failCloudCliTokenManagerError, + CloudCliCredentialRefreshError: failCloudCliTokenManagerError, + CloudCliCredentialReadError: failCloudCliTokenManagerError, + CloudCliAuthorizationError: failCloudCliTokenManagerError, + CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, SecretStoreError: failEnvironmentCloudInternalError( "Could not persist desired T3 Connect link state.", ), From 01cd564557c2e96019f7e7060869097bbe4f6805 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:22:04 -0700 Subject: [PATCH 040/142] [codex] Align desktop preview Effect services (#3199) Co-authored-by: codex --- apps/desktop/src/ipc/methods/preview.ts | 2 +- apps/desktop/src/main.ts | 4 +- apps/desktop/src/preview/BrowserSession.ts | 38 ++-- apps/desktop/src/preview/Manager.ts | 237 +++++++++++---------- 4 files changed, 143 insertions(+), 138 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index cb6e7c51918..99bede9045d 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -96,7 +96,7 @@ const tabMethod = ( channel: string, name: string, invoke: ( - manager: PreviewManager.PreviewManagerShape, + manager: PreviewManager.PreviewManager["Service"], tabId: string, ) => Effect.Effect, ) => diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c9f782d9fc5..310c109ed0f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,7 +46,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"; @@ -133,7 +133,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( ); const desktopPreviewLayer = PreviewManager.layer.pipe( - Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); 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.ts b/apps/desktop/src/preview/Manager.ts index d4bed498021..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); @@ -1224,7 +1225,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(registration)) { - return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId)); + return yield* fail("registerWebview", new PreviewTabNotFoundError({ tabId })); } const { state: registered, pendingUrl } = registration.value; yield* emit(tabId, registered); @@ -2200,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); From ffae12f329c4de9680efd1757b488a3b5bbe3cd2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:23:54 -0700 Subject: [PATCH 041/142] [codex] refactor desktop backend Effect services (#3192) Co-authored-by: codex --- .../DesktopBackendConfiguration.test.ts | 2 +- .../backend/DesktopBackendConfiguration.ts | 87 ++++++------ .../src/backend/DesktopBackendManager.ts | 83 +++++++----- .../DesktopLocalEnvironmentAuth.test.ts | 3 +- .../backend/DesktopLocalEnvironmentAuth.ts | 125 ++++++++++-------- .../src/backend/DesktopNetworkInterfaces.ts | 33 +++++ .../src/backend/DesktopServerExposure.test.ts | 15 ++- .../src/backend/DesktopServerExposure.ts | 124 +++++++---------- .../src/backend/tailscaleEndpointProvider.ts | 10 +- apps/desktop/src/main.ts | 3 +- 10 files changed, 257 insertions(+), 228 deletions(-) create mode 100644 apps/desktop/src/backend/DesktopNetworkInterfaces.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..cb68b2cd47f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -34,7 +34,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, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 5e4e034b5e7..18316743fc6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -14,16 +14,14 @@ 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 DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, - DesktopBackendConfigurationShape + { + readonly resolve: Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} interface BackendObservabilitySettings { @@ -130,40 +128,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.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 index 914b6ada071..cd54c46c89a 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -3,7 +3,8 @@ 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, HttpClientResponse } from "effect/unstable/http"; +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"; diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index e70057ee13c..e619b330d83 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,77 +1,88 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; 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 Semaphore from "effect/Semaphore"; -import { HttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -export interface DesktopLocalEnvironmentAuthShape { - readonly getBearerToken: Effect.Effect; +export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthBackendNotConfiguredError", + {}, +) { + override get message(): string { + return "Local backend is not configured."; + } } -export class DesktopLocalEnvironmentAuthError extends Data.TaggedError( - "DesktopLocalEnvironmentAuthError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} +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, - DesktopLocalEnvironmentAuthShape + { + readonly getBearerToken: Effect.Effect; + } >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} -export const layer = Layer.effect( - DesktopLocalEnvironmentAuth, - 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); +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 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")); - const configOption = yield* backendManager.currentConfig; - if (Option.isNone(configOption)) { - return yield* new DesktopLocalEnvironmentAuthError({ - message: "Local backend is not configured.", - }); - } - 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 DesktopLocalEnvironmentAuthError({ - message: "Failed to create the local desktop bearer session.", - cause, - }), - ), - ); - yield* Ref.set(tokenRef, Option.some(session.access_token)); - return session.access_token; - }), - ) - .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken")); + return DesktopLocalEnvironmentAuth.of({ getBearerToken }); +}); - return DesktopLocalEnvironmentAuth.of({ getBearerToken }); - }), -); +export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts new file mode 100644 index 00000000000..ad8c9eb8b14 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -0,0 +1,33 @@ +import { networkInterfaces } from "node:os"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +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 DesktopNetworkInterfaces extends Context.Service< + DesktopNetworkInterfaces, + { + readonly read: Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} + +export const make = (): DesktopNetworkInterfaces["Service"] => + DesktopNetworkInterfaces.of({ + read: Effect.sync(() => networkInterfaces()), + }); + +export const layer = Layer.succeed(DesktopNetworkInterfaces, make()); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1dd7fa04a79..6bfe2e097ae 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -7,17 +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 * 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 * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); -const emptyNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {}; -const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { +const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { en0: [ { address: "192.168.1.20", @@ -27,7 +28,7 @@ const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { ], }; -const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { +const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { tailscale0: [ { address: "100.90.1.2", @@ -87,13 +88,13 @@ function makeEnvironmentLayer(baseDir: string, env: Record; readonly spawnerLayer?: 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), }); @@ -109,7 +110,7 @@ function makeLayer(input: { } const withHarness = ( - networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, effect: Effect.Effect< A, E, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 8b62323499e..64e65a61c77 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, @@ -10,41 +8,27 @@ import type { DesktopServerExposureMode, 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 +75,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { }; const resolveLanAdvertisedHost = ( - networkInterfaces: DesktopNetworkInterfaces, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, explicitHost: string | undefined, ): string | null => { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); @@ -116,7 +100,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,25 +202,25 @@ 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( +export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass()( "DesktopServerExposurePersistenceError", -)<{ - readonly operation: DesktopServerExposurePersistenceOperation; - readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; -}> { - override get message() { + { + operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]), + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { return `Failed to persist desktop ${this.operation} settings.`; } } @@ -260,36 +244,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 +284,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 +321,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 +342,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 +381,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 @@ -564,10 +537,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/main.ts b/apps/desktop/src/main.ts index 310c109ed0f..a6ffd9cdab1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ 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"; @@ -128,7 +129,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), ); From ccf8331600f323939a796169dfe2598a4c1b59c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:29:25 -0700 Subject: [PATCH 042/142] [codex] Align diagnostics and telemetry Effect services (#3189) Co-authored-by: codex --- .../src/diagnostics/ProcessDiagnostics.ts | 143 ++++++++--- .../ProcessResourceMonitor.test.ts | 23 +- .../src/diagnostics/ProcessResourceMonitor.ts | 44 ++-- .../src/diagnostics/TraceDiagnostics.ts | 21 +- apps/server/src/http.ts | 19 +- .../observability/BrowserTraceCollector.ts | 23 ++ .../src/observability/Layers/Observability.ts | 19 +- .../Services/BrowserTraceCollector.ts | 12 - .../provider/Layers/ProviderService.test.ts | 239 ++++++++++++------ .../src/provider/Layers/ProviderService.ts | 76 +++--- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 4 +- apps/server/src/serverRuntimeStartup.test.ts | 2 +- apps/server/src/serverRuntimeStartup.ts | 2 +- .../{Layers => }/AnalyticsService.test.ts | 13 +- .../{Layers => }/AnalyticsService.ts | 63 +++-- apps/server/src/telemetry/Identify.ts | 18 +- .../telemetry/Services/AnalyticsService.ts | 37 +-- 18 files changed, 461 insertions(+), 299 deletions(-) create mode 100644 apps/server/src/observability/BrowserTraceCollector.ts delete mode 100644 apps/server/src/observability/Services/BrowserTraceCollector.ts rename apps/server/src/telemetry/{Layers => }/AnalyticsService.test.ts (89%) rename apps/server/src/telemetry/{Layers => }/AnalyticsService.ts (72%) diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index f5f746134f2..40e7f347be1 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,80 @@ 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", + { command: Schema.String }, +) { + override get message(): string { + return `Process diagnostics query '${this.command}' timed out.`; + } +} + +class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsQueryFailedError", { - message: Schema.String, + command: Schema.String, + stderr: Schema.optional(Schema.String), cause: Schema.optional(Schema.Defect()), }, -) {} -const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +) { + override get message(): string { + return this.stderr?.trim() || `Failed to query process diagnostics with '${this.command}'.`; + } +} -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 }, +) { + 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; @@ -272,11 +318,7 @@ interface ProcessOutput { } const runProcess = Effect.fn("runProcess")( - function* (input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly errorMessage: string; - }) { + function* (input: { readonly command: string; readonly args: ReadonlyArray }) { 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 @@ -315,14 +357,22 @@ const runProcess = Effect.fn("runProcess")( Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), Effect.flatMap((result) => Option.match(result, { - onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), + onNone: () => + Effect.fail( + new ProcessDiagnosticsQueryTimeoutError({ + command: input.command, + }), + ), onSome: Effect.succeed, }), ), Effect.mapError((cause) => isProcessDiagnosticsError(cause) ? cause - : toProcessDiagnosticsError(input.errorMessage, cause), + : new ProcessDiagnosticsQueryFailedError({ + command: input.command, + cause, + }), ), ), ); @@ -335,11 +385,15 @@ 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", + stderr: result.stderr.trim() || "ps failed.", + }), + ) : Effect.succeed(parsePosixProcessRows(result.stdout)), ), ); @@ -361,12 +415,14 @@ 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", + stderr: result.stderr.trim() || "PowerShell process query failed.", + }), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), ), @@ -390,7 +446,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 +462,18 @@ function assertDescendantPid( return descendant ? Effect.void : Effect.fail( - toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), + new ProcessDiagnosticsNotDescendantError({ + 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 +489,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 +505,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 +527,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..49a9676ab11 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,7 +105,7 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), @@ -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,7 +165,7 @@ describe("ProcessResourceMonitor", () => { }), ]; - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: secondAt, readAtMs: DateTime.toEpochMillis(secondAt), @@ -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,7 +212,7 @@ describe("ProcessResourceMonitor", () => { ], }); - const result = aggregateProcessResourceHistory({ + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ samples, readAt: sampledAt, readAtMs: DateTime.toEpochMillis(sampledAt), diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index efeeb66256d..b6e71dd2423 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -10,14 +10,9 @@ 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 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; @@ -41,38 +36,41 @@ interface MonitorState { readonly lastError: string | null; } -export interface ProcessResourceMonitorShape { - readonly readHistory: ( - input: ServerProcessResourceHistoryInput, - ) => Effect.Effect; -} - 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) { @@ -245,14 +243,14 @@ export function aggregateProcessResourceHistory(input: { }; } -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 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({ @@ -278,7 +276,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { 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); @@ -296,4 +294,4 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { 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/http.ts b/apps/server/src/http.ts index 37baff432fe..032fd501b01 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,13 +24,13 @@ 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 { @@ -39,7 +39,7 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal, } from "./auth/http.ts"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; @@ -47,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 } : {}), @@ -95,7 +95,7 @@ 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) { @@ -117,9 +117,9 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.gen(function* () { yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; - const browserTraceCollector = yield* BrowserTraceCollector; + const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const bodyJson = cast(yield* request.json); @@ -223,14 +223,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/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/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 8581a11213b..fbb8acfb9e6 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -43,14 +43,11 @@ 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 * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; @@ -58,11 +55,11 @@ 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); @@ -280,7 +277,10 @@ function makeProviderServiceLayer() { [ProviderDriverKind.make("cursor")]: cursor.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -293,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, @@ -325,7 +330,10 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const registry = makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter, }); - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -336,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, @@ -345,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); @@ -361,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 @@ -377,7 +390,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () }) : registryBase.getInstanceInfo(instanceId), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -387,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, @@ -419,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) @@ -444,8 +465,11 @@ 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, @@ -463,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, @@ -490,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) @@ -515,7 +544,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance PubSub.subscribe(pubsub), ), }; - const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -525,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, @@ -571,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"), @@ -617,7 +659,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( 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, @@ -626,17 +668,22 @@ 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"); @@ -683,11 +730,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"), @@ -697,7 +751,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"), @@ -735,18 +789,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, @@ -780,7 +841,7 @@ it.effect( 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"), @@ -866,7 +927,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"), @@ -907,7 +968,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { @@ -959,7 +1020,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"), @@ -988,8 +1049,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, { @@ -1019,7 +1080,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, { @@ -1058,7 +1119,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"), @@ -1099,7 +1160,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"), @@ -1152,7 +1213,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"), @@ -1177,7 +1238,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("persists runtime status transitions in provider_session_runtime", () => Effect.gen(function* () { - const provider = yield* ProviderService; + const provider = yield* ProviderService.ProviderService; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = asThreadId("thread-runtime-status"); @@ -1237,15 +1298,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, @@ -1256,7 +1324,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)); @@ -1268,17 +1336,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, @@ -1327,15 +1402,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, @@ -1353,17 +1435,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, @@ -1397,7 +1486,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, @@ -1443,7 +1532,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, @@ -1499,7 +1588,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, @@ -1571,7 +1660,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"), @@ -1649,7 +1738,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"), @@ -1690,7 +1779,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( @@ -1709,7 +1798,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(); @@ -1734,7 +1823,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"), { @@ -1759,7 +1848,7 @@ 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 provider = yield* ProviderService.ProviderService; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ecb1dd2dbd3..c15d50eed62 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -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({ @@ -1071,14 +1080,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/server.test.ts b/apps/server/src/server.test.ts index bf4a77743a2..4b92b34cc62 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -88,7 +88,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Services/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; -import * as BrowserTraceCollector from "./observability/Services/BrowserTraceCollector.ts"; +import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f1e900c0b5a..f3cbe764264 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -17,7 +17,7 @@ import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; @@ -332,7 +332,7 @@ 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(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a11beba794d..2109f4c5458 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -13,7 +13,7 @@ import * as Stream from "effect/Stream"; 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/Services/AnalyticsService.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", () => { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index dab6143e11c..35ac5a06fc9 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -31,7 +31,7 @@ import * as OrchestrationReactor from "./orchestration/Services/OrchestrationRea import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; -import * as AnalyticsService from "./telemetry/Services/AnalyticsService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { 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.ts b/apps/server/src/telemetry/Identify.ts index 364273a9e1d..f7458bcd8c8 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -6,7 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ @@ -19,9 +19,14 @@ const ClaudeJsonSchema = Schema.Struct({ }); class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} + operation: Schema.Literal("hash_identifier"), + algorithm: Schema.Literal("SHA-256"), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to hash telemetry identifier with ${this.algorithm}.`; + } +} const hash = (value: string) => Crypto.Crypto.pipe( @@ -30,7 +35,8 @@ const hash = (value: string) => Effect.mapError( (cause) => new IdentifyUserError({ - message: "Failed to hash identifier", + operation: "hash_identifier", + algorithm: "SHA-256", cause, }), ), @@ -64,7 +70,7 @@ const getClaudeUserId = Effect.gen(function* () { const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const { anonymousIdPath } = yield* ServerConfig; + const { anonymousIdPath } = yield* ServerConfig.ServerConfig; const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( Effect.catch(() => 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"; From b17b6d50c55e3eff4317163d31c4032423d3d2c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:37:43 -0700 Subject: [PATCH 043/142] [codex] Complete relay agent activity Effect cleanup (#3210) Co-authored-by: codex --- .../agentActivity/AgentActivityPublisher.ts | 2 +- .../src/agentActivity/AgentActivityRows.ts | 2 +- infra/relay/src/agentActivity/ApnsClient.ts | 40 ++-- .../relay/src/agentActivity/ApnsDeliveries.ts | 52 ++---- .../src/agentActivity/ApnsDeliveryQueue.ts | 2 +- .../src/agentActivity/DeliveryAttempts.ts | 2 +- infra/relay/src/agentActivity/Devices.ts | 2 +- .../relay/src/agentActivity/LiveActivities.ts | 2 +- .../src/agentActivity/MobileRegistrations.ts | 2 +- .../agentActivity/apnsDeliveryJobs.test.ts | 12 +- .../src/agentActivity/apnsDeliveryJobs.ts | 175 ++++++++++++------ infra/relay/src/http/Api.ts | 31 ++-- 12 files changed, 185 insertions(+), 139 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index d33cc42cd8d..abe05f07da2 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -39,7 +39,7 @@ export class AgentActivityPublisher extends Context.Service< } >()("t3code-relay/agentActivity/AgentActivityPublisher") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const rows = yield* AgentActivityRows.AgentActivityRows; const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 854facfc7c5..7f19378633f 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -71,7 +71,7 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityStateSchema), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 61bfd69bc96..01a3d04bd62 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -40,15 +40,21 @@ export interface ApnsDeliveryResult { readonly apnsId: string | null; } -export class ApnsSigningError extends Schema.TaggedErrorClass()( - "ApnsSigningError", - { - phase: Schema.Literals(["encoding", "signing"]), - cause: Schema.Defect(), - }, +export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( + "ApnsJwtEncodingError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to encode APNs JWT."; + } +} + +export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( + "ApnsJwtSigningError", + { cause: Schema.Defect() }, ) { override get message(): string { - return `Failed during APNs JWT ${this.phase}`; + return "Failed to sign APNs JWT."; } } @@ -59,7 +65,7 @@ export class ApnsHttpRequestError extends Schema.TaggedErrorClass new ApnsSigningError({ cause, phase: "encoding" })), + Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })), ); const payloadJson = yield* encodeApnsJwtPayloadJson({ iss: input.teamId, iat: input.issuedAtUnixSeconds, - }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" }))); + }).pipe(Effect.mapError((cause) => new ApnsJwtEncodingError({ cause }))); const privateKey = Redacted.value(input.privateKey); const header = Encoding.encodeBase64Url(headerJson); @@ -129,7 +141,7 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { }); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, - catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), + catch: (cause) => new ApnsJwtSigningError({ cause }), }); }); @@ -251,7 +263,7 @@ export class ApnsClient extends Context.Service< } >()("t3code-relay/agentActivity/ApnsClient") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index d70808144fc..e0b652823ba 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -21,9 +21,12 @@ import { } from "./agentActivityPayloads.ts"; import * as Apns from "./ApnsClient.ts"; import { - ApnsDeliveryJobInvalid, + ApnsDeliveryJobLiveActivityAggregateMissing, + ApnsDeliveryJobPushNotificationMissing, + ApnsDeliveryJobQueuePayloadInvalid, type ApnsNotificationPayload, SignedApnsDeliveryJob, + isApnsDeliveryJobVerificationError, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, } from "./apnsDeliveryJobs.ts"; @@ -92,17 +95,6 @@ const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( ); const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob); -function apnsErrorMessage(error: Apns.ApnsError): string { - switch (error._tag) { - case "ApnsSigningError": - return "Failed to sign APNs request."; - case "ApnsHttpRequestError": - return "Failed to send APNs request."; - case "ApnsInvalidResponseError": - return "APNs returned an invalid response."; - } -} - function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null { if (!value) { return null; @@ -273,15 +265,6 @@ function isPermanentApnsTokenFailure(result: Apns.ApnsDeliveryResult): boolean { ); } -function isDeliveryJobVerificationError(value: unknown): value is ApnsDeliveryJobVerificationError { - return ( - typeof value === "object" && - value !== null && - "_tag" in value && - (value._tag === "ApnsDeliveryJobInvalid" || value._tag === "ApnsDeliveryJobExpired") - ); -} - function duplicateJobResult(input: { readonly deviceId: string; readonly kind: RelayDeliveryKind; @@ -418,7 +401,7 @@ export class ApnsDeliveries extends Context.Service< } >()("t3code-relay/agentActivity/ApnsDeliveries") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; const liveActivities = yield* LiveActivities.LiveActivities; const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; @@ -497,7 +480,7 @@ const make = Effect.gen(function* () { Effect.succeed({ ok: false, status: 0, - reason: apnsErrorMessage(error), + reason: error.message, apnsId: null, }), ), @@ -614,7 +597,7 @@ const make = Effect.gen(function* () { Effect.succeed({ ok: false, status: 0, - reason: apnsErrorMessage(error), + reason: error.message, apnsId: null, }), ), @@ -657,12 +640,7 @@ const make = Effect.gen(function* () { "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( - Effect.mapError( - () => - new ApnsDeliveryJobInvalid({ - reason: "invalid-queue-payload", - }), - ), + Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()), ); const now = yield* DateTime.now; const payload = verifySignedApnsDeliveryJob({ @@ -670,7 +648,7 @@ const make = Effect.gen(function* () { job: signedJob, nowMs: now.epochMilliseconds, }); - if (isDeliveryJobVerificationError(payload)) { + if (isApnsDeliveryJobVerificationError(payload)) { return yield* payload; } yield* Effect.annotateCurrentSpan({ @@ -683,11 +661,7 @@ const make = Effect.gen(function* () { case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return Effect.fail( - new ApnsDeliveryJobInvalid({ - reason: "missing-live-activity-aggregate", - }), - ); + return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing()); } return sendLiveActivity({ target: { @@ -712,11 +686,7 @@ const make = Effect.gen(function* () { }); case "push_notification": if (payload.notification === null) { - return Effect.fail( - new ApnsDeliveryJobInvalid({ - reason: "missing-push-notification", - }), - ); + return Effect.fail(new ApnsDeliveryJobPushNotificationMissing()); } return sendPushNotification({ target: { diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 33c21cf0d54..980eab16953 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -59,7 +59,7 @@ export class ApnsDeliveryQueue extends Context.Service< } >()("t3code-relay/agentActivity/ApnsDeliveryQueue") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sender = yield* ApnsDeliveryQueueSender; const crypto = yield* Crypto.Crypto; const config = yield* RelayConfiguration.RelayConfiguration; diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 931837818b6..8845588329a 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -82,7 +82,7 @@ function insertValues( }; } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 51a9bd53d64..973c430832c 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -57,7 +57,7 @@ export class Devices extends Context.Service< } >()("t3code-relay/agentActivity/Devices") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return Devices.of({ diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 9ee1274b935..4417f4eba36 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -106,7 +106,7 @@ const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return LiveActivities.of({ diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index b44d24dfa5d..395422b81dd 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -33,7 +33,7 @@ export class MobileRegistrations extends Context.Service< } >()("t3code-relay/agentActivity/MobileRegistrations") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const devices = yield* Devices.Devices; const liveActivities = yield* LiveActivities.LiveActivities; const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index 428dc3a82b6..d65587f0d19 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -69,7 +69,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobSignatureInvalid", }); }); @@ -93,7 +93,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobLiveActivityAggregateMissing", message: "Live Activity start/update jobs require an aggregate.", }); }); @@ -119,7 +119,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected", message: "Push notification jobs must not carry aggregate state.", }); }); @@ -194,7 +194,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobCreatedAtInvalid", message: "Invalid APNs delivery job creation time.", }); expect( @@ -204,7 +204,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobTimeWindowInvalid", message: "Invalid APNs delivery job time window.", }); expect( @@ -214,7 +214,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobTimeWindowTooLong", message: "APNs delivery job time window is too long.", }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 8de33752a9a..6e493943ab4 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -49,49 +49,110 @@ export const SignedApnsDeliveryJob = Schema.Struct({ }); export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; -export class ApnsDeliveryJobInvalid extends Schema.TaggedErrorClass()( - "ApnsDeliveryJobInvalid", - { - reason: Schema.Literals([ - "invalid-queue-payload", - "missing-live-activity-aggregate", - "unexpected-live-activity-notification", - "missing-push-notification", - "unexpected-push-notification-aggregate", - "invalid-created-at", - "invalid-expires-at", - "invalid-time-window", - "time-window-too-long", - "invalid-signature", - ]), - }, +export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobQueuePayloadInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery queue job."; + } +} + +export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityAggregateMissing", + {}, +) { + override get message(): string { + return "Live Activity start/update jobs require an aggregate."; + } +} + +export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityNotificationUnexpected", + {}, +) { + override get message(): string { + return "Live Activity jobs must not carry push notification payloads."; + } +} + +export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationMissing", + {}, ) { override get message(): string { - switch (this.reason) { - case "invalid-queue-payload": - return "Invalid APNs delivery queue job."; - case "missing-live-activity-aggregate": - return "Live Activity start/update jobs require an aggregate."; - case "unexpected-live-activity-notification": - return "Live Activity jobs must not carry push notification payloads."; - case "missing-push-notification": - return "Push notification jobs require a notification payload."; - case "unexpected-push-notification-aggregate": - return "Push notification jobs must not carry aggregate state."; - case "invalid-created-at": - return "Invalid APNs delivery job creation time."; - case "invalid-expires-at": - return "Invalid APNs delivery job expiry."; - case "invalid-time-window": - return "Invalid APNs delivery job time window."; - case "time-window-too-long": - return "APNs delivery job time window is too long."; - case "invalid-signature": - return "Invalid APNs delivery job signature."; - } + return "Push notification jobs require a notification payload."; } } +export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationAggregateUnexpected", + {}, +) { + override get message(): string { + return "Push notification jobs must not carry aggregate state."; + } +} + +export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobCreatedAtInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job creation time."; + } +} + +export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobExpiresAtInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job expiry."; + } +} + +export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job time window."; + } +} + +export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowTooLong", + {}, +) { + override get message(): string { + return "APNs delivery job time window is too long."; + } +} + +export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobSignatureInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job signature."; + } +} + +export const ApnsDeliveryJobInvalid = Schema.Union([ + ApnsDeliveryJobQueuePayloadInvalid, + ApnsDeliveryJobLiveActivityAggregateMissing, + ApnsDeliveryJobLiveActivityNotificationUnexpected, + ApnsDeliveryJobPushNotificationMissing, + ApnsDeliveryJobPushNotificationAggregateUnexpected, + ApnsDeliveryJobCreatedAtInvalid, + ApnsDeliveryJobExpiresAtInvalid, + ApnsDeliveryJobTimeWindowInvalid, + ApnsDeliveryJobTimeWindowTooLong, + ApnsDeliveryJobSignatureInvalid, +]); +export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; + export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpired", { @@ -103,7 +164,13 @@ export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobInvalid({ reason: "time-window-too-long" }); + return new ApnsDeliveryJobTimeWindowTooLong(); } if (expiresAtMs <= input.nowMs) { return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); @@ -235,7 +292,7 @@ export function verifySignedApnsDeliveryJob(input: { payload: input.job.payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobInvalid({ reason: "invalid-signature" }); + return new ApnsDeliveryJobSignatureInvalid(); } return input.job.payload; } diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index adb13e828dd..33bcd187c26 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -777,18 +777,17 @@ export const serverApi = HttpApiBuilder.group( reason: "persistence_failed", traceId, }), - ApnsDeliveryJobInvalid: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), - ApnsDeliveryJobExpired: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), + ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError, ApnsDeliveryJobClaimInFlight: (_error, traceId) => new RelayInternalError({ code: "internal_error", @@ -893,6 +892,14 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { ): Effect.Effect, R> => effect.pipe(Effect.catch(mapError)); } +function mapApnsDeliveryJobInternalError(_error: unknown, traceId: string) { + return new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }); +} + type TaggedErrorTag = Extract["_tag"]; type MapErrorTagCases = { From 4e8ee13d8f54e9aa84f1b15f9d802c30650217ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:59:32 -0700 Subject: [PATCH 044/142] [codex] Align terminal Effect service modules (#3193) Co-authored-by: codex --- .../Layers/ThreadDeletionReactor.ts | 4 +- .../Layers/ProjectSetupScriptRunner.test.ts | 6 +- .../Layers/ProjectSetupScriptRunner.ts | 4 +- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 12 +- .../{Layers/BunPTY.ts => BunPtyAdapter.ts} | 51 +- apps/server/src/terminal/Layers/Manager.ts | 2488 ---------------- .../src/terminal/{Layers => }/Manager.test.ts | 43 +- apps/server/src/terminal/Manager.ts | 2571 +++++++++++++++++ ...NodePTY.test.ts => NodePtyAdapter.test.ts} | 8 +- .../{Layers/NodePTY.ts => NodePtyAdapter.ts} | 100 +- .../{Services/PTY.ts => PtyAdapter.ts} | 42 +- apps/server/src/terminal/Services/Manager.ts | 150 - apps/server/src/ws.ts | 99 +- 14 files changed, 2762 insertions(+), 2818 deletions(-) rename apps/server/src/terminal/{Layers/BunPTY.ts => BunPtyAdapter.ts} (73%) delete mode 100644 apps/server/src/terminal/Layers/Manager.ts rename apps/server/src/terminal/{Layers => }/Manager.test.ts (98%) create mode 100644 apps/server/src/terminal/Manager.ts rename apps/server/src/terminal/{Layers/NodePTY.test.ts => NodePtyAdapter.test.ts} (87%) rename apps/server/src/terminal/{Layers/NodePTY.ts => NodePtyAdapter.ts} (58%) rename apps/server/src/terminal/{Services/PTY.ts => PtyAdapter.ts} (53%) delete mode 100644 apps/server/src/terminal/Services/Manager.ts 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/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de0..91d39a3c1ea 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -5,7 +5,7 @@ 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 * as TerminalManager from "../../terminal/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; @@ -52,7 +52,7 @@ describe("ProjectSetupScriptRunner", () => { ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( - Layer.succeed(TerminalManager, { + Layer.succeed(TerminalManager.TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), write, @@ -114,7 +114,7 @@ describe("ProjectSetupScriptRunner", () => { ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( - Layer.succeed(TerminalManager, { + Layer.succeed(TerminalManager.TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), write, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 61cd043b43b..3c8772641be 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -5,7 +5,7 @@ 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 * as TerminalManager from "../../terminal/Manager.ts"; import { type ProjectSetupScriptRunnerShape, ProjectSetupScriptRunner, @@ -14,7 +14,7 @@ import { const makeProjectSetupScriptRunner = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const terminalManager = yield* TerminalManager; + const terminalManager = yield* TerminalManager.TerminalManager; const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4b92b34cc62..a1213880f14 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -85,7 +85,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as TerminalManager from "./terminal/Services/Manager.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f3cbe764264..4e0cd792f2b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,7 +33,7 @@ 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"; @@ -101,11 +101,11 @@ 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; } }), ); @@ -238,7 +238,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const TerminalLayerLive = TerminalManagerLive.pipe( +const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), ); diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/BunPtyAdapter.ts similarity index 73% rename from apps/server/src/terminal/Layers/BunPTY.ts rename to apps/server/src/terminal/BunPtyAdapter.ts index 82ea1dcb9b9..045da058cf5 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -3,12 +3,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; 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"; + +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; @@ -60,7 +60,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 +76,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 +93,17 @@ 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( + "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", + ); + } + 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 +119,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 6d528f02aa9..00000000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,2488 +0,0 @@ -import { - DEFAULT_TERMINAL_ID, - type TerminalAttachInput, - type TerminalAttachStreamEvent, - type TerminalEvent, - type TerminalMetadataStreamEvent, - type TerminalOpenInput, - type TerminalResizeInput, - 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 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: TerminalManagerShape["resize"] = (input) => - withThreadLock(input.threadId, resizeLocked(input)); - - 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 98% rename from apps/server/src/terminal/Layers/Manager.test.ts rename to apps/server/src/terminal/Manager.test.ts index a55e5244893..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, 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 87% rename from apps/server/src/terminal/Layers/NodePTY.test.ts rename to apps/server/src/terminal/NodePtyAdapter.test.ts index 46840214b66..798e96e3a26 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -5,8 +5,8 @@ import * as Effect from "effect/Effect"; 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 +19,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 +31,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"], diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/NodePtyAdapter.ts similarity index 58% rename from apps/server/src/terminal/Layers/NodePTY.ts rename to apps/server/src/terminal/NodePtyAdapter.ts index 2b19fe4ac51..e7b5406e7b9 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -5,13 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; 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"; let didEnsureSpawnHelperExecutable = false; @@ -56,7 +51,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 +81,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 +94,46 @@ 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); - }), - } satisfies PtyAdapterShape; - }), -); +export const make = Effect.fn("NodePtyAdapter.make")(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 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/Services/PTY.ts b/apps/server/src/terminal/PtyAdapter.ts similarity index 53% rename from apps/server/src/terminal/Services/PTY.ts rename to apps/server/src/terminal/PtyAdapter.ts index 7af78810efa..dafb6f12f4f 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -6,18 +6,30 @@ * * @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(", ")}.`; + const causeMessage = + this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : ""; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`; + } +} export interface PtyExitEvent { exitCode: number; @@ -42,19 +54,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/ws.ts b/apps/server/src/ws.ts index 29ade95d395..01337541cd1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,45 +56,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/Services/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/Services/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "./workspace/Services/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/Services/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/Services/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 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,7 +108,7 @@ 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 isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -248,35 +247,36 @@ 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 automaticGitFetchInterval = serverSettings.getSettings.pipe( @@ -287,7 +287,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; @@ -766,7 +767,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(); @@ -1068,7 +1071,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", }, @@ -1076,7 +1081,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", }, @@ -1559,7 +1566,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, From 7118e43d16b157dd3079f354bd66ca5d772a9a93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:05:35 -0700 Subject: [PATCH 045/142] [codex] Refactor checkpointing Effect services (#3181) Co-authored-by: codex --- .../OrchestrationEngineHarness.integration.ts | 9 +- .../checkpointing/CheckpointDiffQuery.test.ts | 418 +++++++++++++++++ .../{Layers => }/CheckpointDiffQuery.ts | 64 ++- .../{Layers => }/CheckpointStore.test.ts | 24 +- .../src/checkpointing/CheckpointStore.ts | 171 +++++++ .../Layers/CheckpointDiffQuery.test.ts | 421 ------------------ .../checkpointing/Layers/CheckpointStore.ts | 89 ---- .../Services/CheckpointDiffQuery.ts | 49 -- .../checkpointing/Services/CheckpointStore.ts | 101 ----- .../Layers/CheckpointReactor.test.ts | 14 +- .../orchestration/Layers/CheckpointReactor.ts | 4 +- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 8 +- apps/server/src/ws.ts | 2 +- docs/operations/effect-fn-checklist.md | 12 +- docs/reference/encyclopedia.md | 4 +- .../no-manual-effect-runtime-in-tests.ts | 1 - 17 files changed, 677 insertions(+), 716 deletions(-) create mode 100644 apps/server/src/checkpointing/CheckpointDiffQuery.test.ts rename apps/server/src/checkpointing/{Layers => }/CheckpointDiffQuery.ts (80%) rename apps/server/src/checkpointing/{Layers => }/CheckpointStore.test.ts (89%) create mode 100644 apps/server/src/checkpointing/CheckpointStore.ts delete mode 100644 apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts delete mode 100644 apps/server/src/checkpointing/Layers/CheckpointStore.ts delete mode 100644 apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts delete mode 100644 apps/server/src/checkpointing/Services/CheckpointStore.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index bc1229811a2..fa388ba052f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -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"; @@ -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, @@ -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/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 89% rename from apps/server/src/checkpointing/Layers/CheckpointStore.test.ts rename to apps/server/src/checkpointing/CheckpointStore.test.ts index 778956e5206..d796bdfc4c1 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -3,6 +3,7 @@ import path 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), ); @@ -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); @@ -132,7 +130,7 @@ 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); 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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5e36f9f4bab..4bb5afbb476 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -30,8 +30,7 @@ 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"; @@ -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; @@ -328,7 +330,7 @@ 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), @@ -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); 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/server.test.ts b/apps/server/src/server.test.ts index a1213880f14..62ad99b3e9c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -71,7 +71,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.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"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4e0cd792f2b..987ba83deae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,8 +25,8 @@ import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.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"; @@ -232,8 +232,8 @@ const VcsLayerLive = Layer.empty.pipe( ); const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(CheckpointDiffQuery.layer), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 01337541cd1..cff18c94d91 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,7 +56,7 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import * as CheckpointDiffQuery from "./checkpointing/Services/CheckpointDiffQuery.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"; diff --git a/docs/operations/effect-fn-checklist.md b/docs/operations/effect-fn-checklist.md index 1addfdf4dd4..279b5646d32 100644 --- a/docs/operations/effect-fn-checklist.md +++ b/docs/operations/effect-fn-checklist.md @@ -130,12 +130,12 @@ Effect.fn("name")( - [x] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) - [x] Remaining nested callback wrappers in this file -### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`) +### `apps/server/src/checkpointing/CheckpointStore.ts` (`10`) -- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L89) -- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L183) -- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L220) -- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L252) +- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L123) +- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L137) +- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L144) +- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointStore.ts#L151) - [ ] Nested callback wrappers in this file ### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`) @@ -190,7 +190,7 @@ Effect.fn("name")( - [ ] [apps/server/src/persistence/Migrations.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Migrations.ts) (`2`) - [ ] [apps/server/src/open.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/open.ts) (`2`) - [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) -- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`) +- [ ] [apps/server/src/checkpointing/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/CheckpointDiffQuery.ts) (`2`) - [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`) ``` diff --git a/docs/reference/encyclopedia.md b/docs/reference/encyclopedia.md index 76df004e044..82a58fd959c 100644 --- a/docs/reference/encyclopedia.md +++ b/docs/reference/encyclopedia.md @@ -172,8 +172,8 @@ The file patch and changed-file summary for one turn. It is usually computed in [16]: ./provider-architecture.md [17]: ../apps/server/src/provider/Layers/CodexAdapter.ts [18]: ./runtime-modes.md -[19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts -[20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +[19]: ../apps/server/src/checkpointing/CheckpointStore.ts +[20]: ../apps/server/src/checkpointing/CheckpointDiffQuery.ts [21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts [22]: ../apps/server/src/checkpointing/Utils.ts [23]: ../apps/server/src/checkpointing/Diffs.ts diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index 7494476e27e..e6eff1e5c21 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -25,7 +25,6 @@ const LEGACY_BASELINE = new Map([ ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], - ["apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts", 5], ["apps/server/src/orchestration/commandInvariants.test.ts", 6], ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], From d9e95398622e158f78cffba5367a9d154ccc55e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:13:36 -0700 Subject: [PATCH 046/142] [codex] Migrate server source control Effect services (#3186) Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 24 +- apps/server/src/git/GitManager.ts | 139 ++-- .../server/src/git/GitWorkflowService.test.ts | 4 +- apps/server/src/git/GitWorkflowService.ts | 130 ++-- .../Layers/ProviderCommandReactor.test.ts | 6 +- .../src/sourceControl/AzureDevOpsCli.test.ts | 2 +- .../src/sourceControl/AzureDevOpsCli.ts | 140 ++-- .../AzureDevOpsSourceControlProvider.test.ts | 9 +- .../AzureDevOpsSourceControlProvider.ts | 26 +- .../src/sourceControl/BitbucketApi.test.ts | 32 +- apps/server/src/sourceControl/BitbucketApi.ts | 140 ++-- .../BitbucketSourceControlProvider.test.ts | 12 +- .../BitbucketSourceControlProvider.ts | 16 +- .../src/sourceControl/GitHubCli.test.ts | 2 +- apps/server/src/sourceControl/GitHubCli.ts | 121 ++-- .../GitHubSourceControlProvider.test.ts | 7 +- .../GitHubSourceControlProvider.ts | 40 +- .../src/sourceControl/GitLabCli.test.ts | 2 +- apps/server/src/sourceControl/GitLabCli.ts | 129 ++-- .../GitLabSourceControlProvider.test.ts | 9 +- .../GitLabSourceControlProvider.ts | 49 +- .../SourceControlDiscovery.test.ts | 24 +- .../sourceControl/SourceControlDiscovery.ts | 147 ++-- .../sourceControl/SourceControlProvider.ts | 94 ++- .../SourceControlProviderDiscovery.ts | 6 +- .../SourceControlProviderRegistry.test.ts | 18 +- .../SourceControlProviderRegistry.ts | 74 +- .../SourceControlRepositoryService.test.ts | 10 +- .../SourceControlRepositoryService.ts | 28 +- apps/server/src/vcs/GitVcsDriver.test.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 185 ++--- apps/server/src/vcs/GitVcsDriverCore.ts | 252 +++---- apps/server/src/vcs/VcsDriver.ts | 49 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 4 +- apps/server/src/vcs/VcsDriverRegistry.ts | 39 +- apps/server/src/vcs/VcsProcess.ts | 23 +- apps/server/src/vcs/VcsProjectConfig.ts | 23 +- .../src/vcs/VcsProvisioningService.test.ts | 2 +- apps/server/src/vcs/VcsProvisioningService.ts | 14 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 4 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 631 +++++++++--------- apps/server/src/ws.ts | 6 +- 42 files changed, 1336 insertions(+), 1338 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index cc7340f965a..165c351b36c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -376,7 +376,7 @@ function createTextGeneration( } function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCliShape; + service: GitHubCli.GitHubCli["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -388,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCli.GitHubCliShape["execute"] = (input) => { + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -609,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } function runStackedAction( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; @@ -618,7 +618,7 @@ function runStackedAction( featureBranch?: boolean; filePaths?: readonly string[]; }, - options?: Parameters[1], + options?: Parameters[1], ) { return manager.runStackedAction( { @@ -630,14 +630,14 @@ function runStackedAction( } function resolvePullRequest( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: { cwd: string; reference: string }, ) { return manager.resolvePullRequest(input); } function preparePullRequestThread( - manager: GitManager.GitManagerShape, + manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); @@ -646,11 +646,11 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape; + setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); @@ -663,7 +663,7 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make().pipe( + GitHubSourceControlProvider.make.pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -688,7 +688,7 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return GitManager.makeGitManager().pipe( + return GitManager.make.pipe( Effect.provide(managerLayer), Effect.map((manager) => ({ manager, ghCalls })), ); @@ -697,9 +697,7 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; const GitManagerTestLayer = GitVcsDriver.layer.pipe( - Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }), - ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 94f3ee5b435..9938c40cffb 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -42,17 +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/Services/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 GitRemoteStatusOptions, - 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 { @@ -64,35 +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, - options?: 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; -} - -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; @@ -531,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), @@ -721,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) @@ -752,7 +747,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, - options?: GitRemoteStatusOptions, + options?: GitVcsDriver.GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -1358,11 +1353,13 @@ 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) { @@ -1371,43 +1368,43 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { 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) => { @@ -1608,7 +1605,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()); @@ -1787,7 +1784,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); - return { + return GitManager.of({ localStatus, remoteStatus, status, @@ -1797,7 +1794,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 0af4847f4ac..f958b663006 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -28,68 +28,70 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; -import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; -import { GitVcsDriver, type GitRemoteStatusOptions } 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, - options?: 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?: 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>; -} +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) => @@ -142,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, @@ -334,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/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a08da26ba59..0e399f03ab8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.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); @@ -348,9 +348,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/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..5041fe6635b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -53,41 +53,41 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; - readonly git?: Partial; + readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => 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 +106,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }), - } satisfies Partial; + } satisfies Partial; const layer = BitbucketApi.layer.pipe( Layer.provide( @@ -130,7 +130,7 @@ function makeLayer(input: { expiresAt: Option.none(), }, }, - driver: driver as unknown as VcsDriver.VcsDriverShape, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], }), }), ), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 632778eca24..43a1a705e67 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; @@ -350,10 +346,6 @@ function requestError(operation: string, cause: unknown): BitbucketApiError { }); } -function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { - return isBitbucketApiErrorValue(cause); -} - function responseError( operation: string, response: HttpClientResponse.HttpClientResponse, @@ -375,7 +367,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; @@ -511,7 +503,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 +591,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 +663,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) => @@ -766,4 +754,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/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 ff0d644901d..e0c19bd3428 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -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"; @@ -188,81 +188,84 @@ export interface GitRemoteStatusOptions { readonly refreshUpstream?: boolean; } -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, - 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; -} - -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; @@ -357,7 +360,7 @@ function parseGitRemoteVerboseOutput( } const gitCommand = ( - process: VcsProcess.VcsProcessShape, + process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, args: ReadonlyArray, @@ -401,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", @@ -414,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 } : {}), @@ -426,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))) { @@ -452,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", @@ -494,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, @@ -540,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) { @@ -587,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, @@ -844,7 +847,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }; - return VcsDriver.VcsDriver.of({ + return { capabilities, execute, checkpoints, @@ -854,18 +857,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.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b78fba1030e..0e8f8df16e2 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, @@ -1426,35 +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", - )(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 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* 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, @@ -1471,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, @@ -1546,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); @@ -1664,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); @@ -1710,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`; @@ -1921,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()), @@ -2165,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; @@ -2188,7 +2186,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( @@ -2207,7 +2205,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")( + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { yield* executeGit( "GitVcsDriver.fetchRemote", @@ -2221,7 +2219,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] = + const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"] = Effect.fn("resolveRemoteTrackingCommit")(function* (input) { const remoteNames = yield* listRemoteNames(input.cwd); const parsedRemoteRef = parseRemoteRefWithRemoteNames( @@ -2239,7 +2237,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha, remoteRefName }; }); - const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ @@ -2261,7 +2259,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", @@ -2272,7 +2270,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", @@ -2280,7 +2278,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"]; @@ -2304,28 +2302,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( [ @@ -2407,7 +2405,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, @@ -2421,13 +2419,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", 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.ts b/apps/server/src/vcs/VcsProcess.ts index a4caf7d3230..4470a1bfc53 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.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 Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -10,8 +11,7 @@ import { 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,13 +35,12 @@ 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; @@ -51,8 +50,8 @@ function commandLabel(command: string, args: ReadonlyArray): string { return [command, ...args].join(" "); } -export const make = Effect.fn("makeVcsProcess")(function* () { - const processRunner = yield* ProcessRunner; +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); @@ -119,4 +118,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.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 10ecfa7fd96..c3590f5dbb0 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -27,15 +27,14 @@ export interface VcsProjectConfigResolveInput { readonly requestedKind?: VcsDriverKindType | "auto"; } -export interface VcsProjectConfigShape { - readonly resolveKind: ( - input: VcsProjectConfigResolveInput, - ) => Effect.Effect; -} - -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"; @@ -44,7 +43,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto const parseConfig = (raw: string): Option.Option => decodeProjectVcsConfigJson(raw); -export const make = Effect.fn("makeVcsProjectConfig")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -91,7 +90,7 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { return configuredKind(parsed.value); }); - const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( "VcsProjectConfig.resolveKind", )(function* (input) { if (input.requestedKind !== undefined && input.requestedKind !== "auto") { @@ -110,4 +109,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 d78999f88c1..c14115e7119 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -299,7 +299,7 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - } satisfies Partial), + } satisfies Partial), ), ); @@ -617,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 f0cacab2dcb..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,263 +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, - 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 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 needsInitialRefreshRef = yield* Ref.make(refreshImmediately); - const refreshRemoteStatusIfEnabled = Effect.gen(function* () { - const configuredInterval = yield* automaticRemoteRefreshInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval; - const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); - if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { - return activeInterval; - } - - const 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; - } - - 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/ws.ts b/apps/server/src/ws.ts index cff18c94d91..7eb4ba882d9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -92,7 +92,7 @@ import * as EnvironmentAuth 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 * 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"; @@ -278,7 +278,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => 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) => @@ -1669,7 +1669,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( From 6b7447632292f54dc98244320a7f47d7d3858ad4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:16:45 -0700 Subject: [PATCH 047/142] Add Effect service conventions check (#3212) Co-authored-by: codex --- .../effect-service-conventions.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .macroscope/check-run-agents/effect-service-conventions.md 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..bcb454a6641 --- /dev/null +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -0,0 +1,70 @@ +--- +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 `electron`, 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. +- 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`. From 2f8d3baa88cd137b11083942a4961ca79a9f99be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:21:58 -0700 Subject: [PATCH 048/142] [codex] finish server process and preview Effect cleanup (#3209) Co-authored-by: codex --- apps/server/src/preview/PortScanner.test.ts | 4 +- .../src/process/externalLauncher.test.ts | 20 ++--- apps/server/src/process/externalLauncher.ts | 59 +++++++++++--- apps/server/src/server.test.ts | 7 +- packages/contracts/src/editor.ts | 78 +++++++++++++++++-- 5 files changed, 139 insertions(+), 29 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 481d28d782f..9216c696008 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -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( 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 e8cfce0e96a..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"; @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne // 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; } @@ -317,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) { @@ -326,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); @@ -349,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), ); }); @@ -357,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* ( @@ -369,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, }); } @@ -387,7 +419,14 @@ 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, + }), ); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 62ad99b3e9c..1529285e50c 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, @@ -4570,8 +4570,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: { diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index c180cf24294..5948d87e1d2 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -export class ExternalLauncherError extends Schema.TaggedErrorClass()( - "ExternalLauncherError", +export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnknownEditorError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + editor: Schema.String, }, -) {} +) { + override get message(): string { + return `Unknown editor: ${this.editor}`; + } +} + +export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass()( + "ExternalLauncherUnsupportedEditorError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `Unsupported editor: ${this.editor}`; + } +} + +export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass()( + "ExternalLauncherCommandNotFoundError", + { + editor: EditorId, + command: Schema.String, + }, +) { + override get message(): string { + return `Editor command not found: ${this.command}`; + } +} + +const ExternalLauncherSpawnFields = { + command: Schema.String, + args: Schema.Array(Schema.String), + cause: Schema.Defect(), +}; + +export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherBrowserSpawnError", + { + ...ExternalLauncherSpawnFields, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass()( + "ExternalLauncherEditorSpawnError", + { + ...ExternalLauncherSpawnFields, + editor: EditorId, + target: Schema.String, + }, +) { + override get message(): string { + return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`; + } +} + +export const ExternalLauncherError = Schema.Union([ + ExternalLauncherUnknownEditorError, + ExternalLauncherUnsupportedEditorError, + ExternalLauncherCommandNotFoundError, + ExternalLauncherBrowserSpawnError, + ExternalLauncherEditorSpawnError, +]); +export type ExternalLauncherError = typeof ExternalLauncherError.Type; + +export const isExternalLauncherError = Schema.is(ExternalLauncherError); From 0ad1e9d9e4bf691ad1dcfb5af69de11b0b48047f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:31:45 -0700 Subject: [PATCH 049/142] [codex] Refactor client-runtime Effect services (#3198) Co-authored-by: codex --- apps/mobile/src/connection/catalog-store.ts | 2 +- apps/mobile/src/connection/platform.ts | 100 ++++--- apps/mobile/src/connection/runtime.ts | 24 +- apps/mobile/src/connection/storage.ts | 20 +- apps/web/src/cloud/linkEnvironment.test.ts | 6 +- apps/web/src/connection/platform.ts | 119 ++++---- apps/web/src/connection/runtime.ts | 24 +- apps/web/src/connection/storage.test.ts | 2 +- apps/web/src/connection/storage.ts | 20 +- .../client-runtime/src/authorization/index.ts | 9 +- .../src/authorization/layer.test.ts | 47 ++- .../client-runtime/src/authorization/layer.ts | 268 ------------------ .../src/authorization/service.ts | 267 ++++++++++++++++- .../src/authorization/tokenStore.ts | 7 + .../client-runtime/src/connection/catalog.ts | 28 -- .../src/connection/connectivity.ts | 6 + .../src/connection/credentialStore.ts | 27 ++ .../client-runtime/src/connection/driver.ts | 70 +++-- .../client-runtime/src/connection/errors.ts | 34 +-- .../client-runtime/src/connection/index.ts | 37 ++- .../client-runtime/src/connection/layer.ts | 40 ++- .../client-runtime/src/connection/model.ts | 57 ++-- .../src/connection/onboarding.ts | 99 ++++--- .../src/connection/presentation.test.ts | 6 +- .../src/connection/profileStore.ts | 24 ++ .../src/connection/registry.test.ts | 129 ++++----- .../client-runtime/src/connection/registry.ts | 209 +++++++------- .../src/connection/resolver.test.ts | 89 +++--- .../client-runtime/src/connection/resolver.ts | 96 +++---- .../src/connection/supervisor.test.ts | 87 +++--- .../src/connection/supervisor.ts | 93 +++--- .../client-runtime/src/connection/wakeups.ts | 6 + .../src/operations/commands.test.ts | 19 +- .../src/platform/storageDocument.test.ts | 4 +- .../src/platform/storageDocument.ts | 4 +- .../src/relay/discovery.test.ts | 82 +++--- .../client-runtime/src/relay/discovery.ts | 39 ++- .../client-runtime/src/rpc/client.test.ts | 33 ++- packages/client-runtime/src/rpc/index.ts | 2 +- .../client-runtime/src/rpc/session.test.ts | 6 +- packages/client-runtime/src/rpc/session.ts | 148 +++++----- .../client-runtime/src/state/connections.ts | 32 ++- .../src/state/shell-sync.test.ts | 23 +- .../src/state/threads-sync.test.ts | 23 +- 44 files changed, 1281 insertions(+), 1186 deletions(-) delete mode 100644 packages/client-runtime/src/authorization/layer.ts create mode 100644 packages/client-runtime/src/connection/credentialStore.ts create mode 100644 packages/client-runtime/src/connection/profileStore.ts diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index 0682b25ae38..b5bda400670 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -18,7 +18,7 @@ export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 8b959b40b15..769632a8fcb 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -10,8 +10,8 @@ import { import { ConnectionBlockedError, ConnectionTransientError, - ConnectionWakeups, Connectivity, + Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { AuthStandardClientScopes } from "@t3tools/contracts"; @@ -41,53 +41,47 @@ function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "on return "unknown"; } -const connectivityLayer = Layer.succeed( - Connectivity, - Connectivity.of({ - status: Effect.tryPromise({ - try: () => Network.getNetworkStateAsync(), - catch: () => undefined, - }).pipe( - Effect.match({ - onFailure: () => "unknown" as const, - onSuccess: networkStatus, - }), - ), - changes: Stream.callback((queue) => +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(() => - Network.addNetworkStateListener((state) => { - Queue.offerUnsafe(queue, networkStatus(state)); + AppState.addEventListener("change", (state) => { + if (state === "active") { + Queue.offerUnsafe(queue, "application-active"); + } }), ), (subscription) => Effect.sync(() => subscription.remove()), ).pipe(Effect.asVoid), ), - }), -); - -const wakeupsLayer = Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ - 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), - ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), ), - }), -); + ), +}); const capabilitiesLayer = Layer.succeedContext( Context.make( @@ -98,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Cloud to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -106,14 +100,14 @@ const capabilitiesLayer = Layer.succeedContext( (error) => new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }), ), ); if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The T3 Cloud session is unavailable.", + detail: "The T3 Cloud session is unavailable.", }); } return token; @@ -132,7 +126,7 @@ const capabilitiesLayer = Layer.succeedContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not load the mobile device identity: ${String(cause)}`, + detail: `Could not load the mobile device identity: ${String(cause)}`, }), }).pipe(Effect.map(Option.some)), }), @@ -151,14 +145,14 @@ const capabilitiesLayer = Layer.succeedContext( Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }), ), prepare: () => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }), ), disconnect: () => Effect.void, @@ -195,7 +189,19 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( }), ); -export const connectionPlatformLayer = Layer.mergeAll( +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, diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 3b1eade0818..f35b938dc6c 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -1,16 +1,28 @@ -import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +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), -); +const providedConnectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof providedConnectionPlatformLayer; -export const connectionLayer = clientConnectionLayer.pipe( +export const connectionLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); -export const connectionAtomRuntime = Atom.runtime(connectionLayer); +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 5754d655633..276ea3c5c08 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -8,11 +8,11 @@ import { removeCatalogValue, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; -import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; import { - ConnectionCredentialStore, - ConnectionProfileStore, ConnectionTransientError, + CredentialStore, + ProfileStore, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, @@ -58,7 +58,7 @@ const LegacyStoredShellSnapshot = Schema.Struct({ function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } @@ -197,7 +197,7 @@ export const connectionStorageLayer = Layer.effectContext( .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ProfileStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -221,7 +221,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = CredentialStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -248,7 +248,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + const remoteTokenStore = TokenStore.make({ get: (environmentId) => catalog.read.pipe( Effect.map((document) => @@ -423,9 +423,9 @@ export const connectionStorageLayer = Layer.effectContext( return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), - Context.add(ConnectionProfileStore, profileStore), - Context.add(ConnectionCredentialStore, credentialStore), - Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), ); }), diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index fe639d9c594..51251975557 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -15,9 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { afterEach, beforeEach, vi } from "vite-plus/test"; import { AVAILABLE_CONNECTION_STATE, - type EnvironmentRegistryService, EnvironmentSupervisor, - type EnvironmentSupervisorService, type PreparedConnection, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -114,13 +112,13 @@ function registryLayer(options?: { connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); + } 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 EnvironmentRegistryService; + } as unknown as EnvironmentRegistry["Service"]; return EnvironmentRegistry.of(registry); }), ); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c8426d5510b..1b7cba5cbfd 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -10,11 +10,11 @@ import { import { ConnectionBlockedError, ConnectionTransientError, - ConnectionWakeups, Connectivity, mapRemoteEnvironmentError, PrimaryConnectionRegistration, PrimaryConnectionTarget, + Wakeups, } from "@t3tools/client-runtime/connection"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; @@ -50,56 +50,50 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" { return navigator.onLine ? "online" : "offline"; } -const connectivityLayer = Layer.succeed( - Connectivity, - Connectivity.of({ - status: Effect.sync(currentNetworkStatus), - changes: Stream.callback((queue) => +const connectivityLayer = Connectivity.layer({ + status: Effect.sync(currentNetworkStatus), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const online = () => Queue.offerUnsafe(queue, "online"); + const offline = () => Queue.offerUnsafe(queue, "offline"); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + return { online, offline }; + }), + ({ online, offline }) => + Effect.sync(() => { + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => Effect.acquireRelease( Effect.sync(() => { - const online = () => Queue.offerUnsafe(queue, "online"); - const offline = () => Queue.offerUnsafe(queue, "offline"); - window.addEventListener("online", online); - window.addEventListener("offline", offline); - return { online, offline }; + const listener = () => { + if (document.visibilityState === "visible") { + Queue.offerUnsafe(queue, "application-active"); + } + }; + document.addEventListener("visibilitychange", listener); + return listener; }), - ({ online, offline }) => + (listener) => Effect.sync(() => { - window.removeEventListener("online", online); - window.removeEventListener("offline", offline); + document.removeEventListener("visibilitychange", listener); }), ).pipe(Effect.asVoid), ), - }), -); - -const wakeupsLayer = Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ - changes: Stream.merge( - Stream.callback<"application-active">((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const listener = () => { - if (document.visibilityState === "visible") { - Queue.offerUnsafe(queue, "application-active"); - } - }; - document.addEventListener("visibilitychange", listener); - return listener; - }), - (listener) => - Effect.sync(() => { - document.removeEventListener("visibilitychange", listener); - }), - ).pipe(Effect.asVoid), - ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), - ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), ), - }), -); + ), +}); function clientMetadata() { const desktop = window.desktopBridge !== undefined; @@ -116,12 +110,12 @@ function sshPreparationError(cause: unknown) { if (message.toLowerCase().includes("cancel")) { return new ConnectionBlockedError({ reason: "authentication", - message, + detail: message, }); } return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not prepare the SSH environment: ${message}`, + detail: `Could not prepare the SSH environment: ${message}`, }); } @@ -139,7 +133,7 @@ export const provisionDesktopSshEnvironment = Effect.fn( if (pairingToken === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The SSH environment did not issue a pairing credential.", + detail: "The SSH environment did not issue a pairing credential.", }); } const descriptor = yield* Effect.tryPromise({ @@ -170,7 +164,7 @@ const capabilitiesLayer = Layer.effectContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Cloud to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -178,14 +172,14 @@ const capabilitiesLayer = Layer.effectContext( (error) => new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }), ), ); if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The T3 Cloud session is unavailable.", + detail: "The T3 Cloud session is unavailable.", }); } return token; @@ -200,7 +194,7 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not load the desktop primary credential: ${String(cause)}`, + detail: `Could not load the desktop primary credential: ${String(cause)}`, }), }).pipe(Effect.map(Option.fromNullishOr)), }); @@ -210,7 +204,7 @@ const capabilitiesLayer = Layer.effectContext( if (bridge === undefined) { return yield* new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }); } return yield* provisionDesktopSshEnvironment(bridge, target); @@ -220,7 +214,7 @@ const capabilitiesLayer = Layer.effectContext( if (bridge === undefined) { return yield* new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }); } const bootstrap = yield* Effect.tryPromise({ @@ -233,7 +227,7 @@ const capabilitiesLayer = Layer.effectContext( if (bootstrap.pairingToken === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The SSH environment did not issue a pairing credential.", + detail: "The SSH environment did not issue a pairing credential.", }); } const access = yield* Effect.tryPromise({ @@ -256,7 +250,7 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not disconnect the SSH environment: ${String(cause)}`, + detail: `Could not disconnect the SSH environment: ${String(cause)}`, }), }); }), @@ -278,7 +272,7 @@ const loadPrimaryConnectionRegistration = Effect.fn( if (resolved === null) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Unable to resolve the primary environment endpoint.", + detail: "Unable to resolve the primary environment endpoint.", }); } const descriptor = yield* fetchRemoteEnvironmentDescriptor({ @@ -342,7 +336,20 @@ const rpcRequestObserverLayer = Layer.succeed( }), ); -export const connectionPlatformLayer = Layer.mergeAll( +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer + | typeof rpcRequestObserverLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( connectionStorageLayer, connectivityLayer, wakeupsLayer, diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index 3b1eade0818..f35b938dc6c 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -1,16 +1,28 @@ -import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +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), -); +const providedConnectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof providedConnectionPlatformLayer; -export const connectionLayer = clientConnectionLayer.pipe( +export const connectionLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); -export const connectionAtomRuntime = Atom.runtime(connectionLayer); +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 0f0656dee98..6d503387bb6 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -43,7 +43,7 @@ describe("makeCatalogStore", () => { Effect.gen(function* () { const failure = new ConnectionTransientError({ reason: "remote-unavailable", - message: "permission denied", + detail: "permission denied", }); const store = yield* makeCatalogStore({ read: Effect.fail(failure), diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 19a4a8454ed..d118a428ed7 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -11,11 +11,11 @@ import { removeConnectionFromCatalog, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; -import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; import { - ConnectionCredentialStore, - ConnectionProfileStore, ConnectionTransientError, + CredentialStore, + ProfileStore, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, @@ -63,7 +63,7 @@ const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson) function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } @@ -343,7 +343,7 @@ export const connectionStorageLayer = Layer.effectContext( .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ProfileStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -367,7 +367,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = CredentialStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -394,7 +394,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + const remoteTokenStore = TokenStore.make({ get: (environmentId) => catalog.read.pipe( Effect.map((document) => @@ -527,9 +527,9 @@ export const connectionStorageLayer = Layer.effectContext( return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), - Context.add(ConnectionProfileStore, profileStore), - Context.add(ConnectionCredentialStore, credentialStore), - Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), ); }), diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts index 06137d1fd5c..6236b5922d8 100644 --- a/packages/client-runtime/src/authorization/index.ts +++ b/packages/client-runtime/src/authorization/index.ts @@ -1,4 +1,7 @@ -export * from "./layer.ts"; export * from "./remote.ts"; -export * from "./service.ts"; -export * from "./tokenStore.ts"; +export { + type AuthorizedRemoteEnvironment, + type RelayEnvironmentAuthorization, + RemoteEnvironmentAuthorization, +} from "./service.ts"; +export * as TokenStore from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index b65eacaa794..d950c241d50 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -5,12 +5,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; -import { ClientPresentation } from "../platform/capabilities.ts"; -import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; -import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "./service.ts"; +import * as TokenStore from "./tokenStore.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -30,7 +29,7 @@ const DESCRIPTOR = { repositoryIdentity: true, }, }; -const BOOTSTRAP: RelayEnvironmentAuthorization = { +const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = { environmentId: ENVIRONMENT_ID, endpoint: ENDPOINT, credential: "relay-bootstrap", @@ -76,7 +75,7 @@ const authInvalid = () => ); const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { - readonly initialToken?: RemoteDpopAccessToken; + readonly initialToken?: TokenStore.RemoteDpopAccessToken; readonly responses: ReadonlyArray; }) { const tokens = yield* Ref.make( @@ -96,7 +95,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( >([]); const fetch = recordedFetch(input.responses); - const tokenStore = RemoteDpopAccessTokenStore.of({ + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ get: (environmentId) => Ref.get(tokens).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), @@ -114,23 +113,23 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( return next; }), }); - const signer = ManagedRelayDpopSigner.of({ + const signer = ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("thumbprint-1"), createProof: (proofInput) => Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( Effect.as(`proof:${proofInput.url}`), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })), ), }); - const layer = remoteEnvironmentAuthorizationLayer.pipe( + const layer = RemoteEnvironmentAuthorization.layer.pipe( Layer.provide( Layer.mergeAll( remoteHttpClientLayer(fetch.fetchFn), - Layer.succeed(ManagedRelayDpopSigner, signer), - Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), Layer.succeed( - ClientPresentation, - ClientPresentation.of({ + ClientCapabilities.ClientPresentation, + ClientCapabilities.ClientPresentation.of({ metadata: { label: "T3 Code Test", deviceType: "mobile", @@ -159,7 +158,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( describe("RemoteEnvironmentAuthorization", () => { it.effect("reuses a valid persisted environment token without contacting the relay", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -173,7 +172,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -191,7 +190,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("refreshes and persists an expired environment token", () => Effect.gen(function* () { - const expired = new RemoteDpopAccessToken({ + const expired = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -209,7 +208,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -230,7 +229,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -249,7 +248,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -269,7 +268,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("refreshes a cached endpoint after consecutive transient failures", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -289,7 +288,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; const firstFailure = yield* remote .authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, @@ -329,7 +328,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, diff --git a/packages/client-runtime/src/authorization/layer.ts b/packages/client-runtime/src/authorization/layer.ts deleted file mode 100644 index 9b71edf0461..00000000000 --- a/packages/client-runtime/src/authorization/layer.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - exchangeRemoteDpopAccessToken, - type RemoteEnvironmentAuthError, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, -} from "./remote.ts"; -import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; -import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; -import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; -import { environmentEndpointUrl } from "../environment/endpoint.ts"; -import { ClientPresentation } from "../platform/capabilities.ts"; -import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; -import { RemoteEnvironmentAuthorization } from "./service.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import { HttpClient } from "effect/unstable/http"; - -const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; -const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; - -function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { - return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" - ? error - : mapRemoteEnvironmentError(error); -} - -const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( - httpBaseUrl: string, -) { - return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - ); -}); - -export const remoteEnvironmentAuthorizationLayer = Layer.effect( - RemoteEnvironmentAuthorization, - Effect.gen(function* () { - const signer = yield* ManagedRelayDpopSigner; - const presentation = yield* ClientPresentation; - const tokenStore = yield* RemoteDpopAccessTokenStore; - const httpClient = yield* HttpClient.HttpClient; - const cachedEndpointFailures = yield* Ref.make>(new Map()); - - const resetCachedEndpointFailures = (environmentId: string) => - Ref.update(cachedEndpointFailures, (current) => { - if (!current.has(environmentId)) { - return current; - } - const next = new Map(current); - next.delete(environmentId); - return next; - }); - - const recordCachedEndpointFailure = (environmentId: string) => - Ref.modify(cachedEndpointFailures, (current) => { - const failureCount = (current.get(environmentId) ?? 0) + 1; - const next = new Map(current); - next.set(environmentId, failureCount); - return [failureCount, next] as const; - }); - - const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( - function* (input: { - readonly expectedEnvironmentId: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] - >[0]["expectedEnvironmentId"]; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly bearerToken: string; - }) { - const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - if (descriptor.environmentId !== input.expectedEnvironmentId) { - return yield* environmentMismatchError({ - expected: input.expectedEnvironmentId, - actual: descriptor.environmentId, - }); - } - const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: input.wsBaseUrl, - httpBaseUrl: input.httpBaseUrl, - bearerToken: input.bearerToken, - }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: input.httpBaseUrl, - socketUrl, - httpAuthorization: { - _tag: "Bearer" as const, - token: input.bearerToken, - }, - }; - }, - ); - - const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( - function* (token: RemoteDpopAccessToken) { - const ticketProof = yield* signer - .createProof({ - method: "POST", - url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), - accessToken: token.accessToken, - }) - .pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not create the websocket authorization proof.", - }), - ), - ); - return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: token.endpoint.wsBaseUrl, - httpBaseUrl: token.endpoint.httpBaseUrl, - accessToken: token.accessToken, - dpopProof: ticketProof, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); - }, - ); - - const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( - function* (input: { - readonly expectedEnvironmentId: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] - >[0]["expectedEnvironmentId"]; - readonly obtainBootstrap: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] - >[0]["obtainBootstrap"]; - }) { - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not load the environment authorization key.", - }), - ), - Effect.withSpan("environment.authorization.dpopKey.resolve"), - ); - const now = yield* Clock.currentTimeMillis; - const cached = yield* tokenStore - .get(input.expectedEnvironmentId) - .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); - if ( - Option.isSome(cached) && - cached.value.environmentId === input.expectedEnvironmentId && - cached.value.dpopThumbprint === thumbprint && - cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS - ) { - yield* Effect.annotateCurrentSpan({ - "connection.remote_token_cache": "hit", - }); - const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); - if (Result.isSuccess(cachedSocket)) { - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - return { - environmentId: cached.value.environmentId, - label: cached.value.label, - httpBaseUrl: cached.value.endpoint.httpBaseUrl, - socketUrl: cachedSocket.success, - httpAuthorization: { - _tag: "Dpop" as const, - accessToken: cached.value.accessToken, - }, - }; - } - if (cachedSocket.failure._tag === "ConnectionBlockedError") { - return yield* mapDpopSocketError(cachedSocket.failure); - } - const mappedFailure = mapDpopSocketError(cachedSocket.failure); - if (mappedFailure._tag === "ConnectionTransientError") { - const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); - if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { - return yield* mappedFailure; - } - } - yield* tokenStore - .remove(input.expectedEnvironmentId) - .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - } - - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - yield* Effect.annotateCurrentSpan({ - "connection.remote_token_cache": "miss", - }); - const bootstrap = yield* input.obtainBootstrap; - const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.withSpan("environment.authorization.descriptor"), - ); - if (descriptor.environmentId !== input.expectedEnvironmentId) { - return yield* environmentMismatchError({ - expected: input.expectedEnvironmentId, - actual: descriptor.environmentId, - }); - } - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), - }) - .pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not create the environment authorization proof.", - }), - ), - ); - const access = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: bootstrap.endpoint.httpBaseUrl, - credential: bootstrap.credential, - dpopProof: bootstrapProof, - scopes: presentation.scopes, - clientMetadata: presentation.metadata, - }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.withSpan("environment.authorization.accessToken.exchange"), - ); - const issuedAt = yield* Clock.currentTimeMillis; - const token = new RemoteDpopAccessToken({ - environmentId: descriptor.environmentId, - label: descriptor.label, - endpoint: bootstrap.endpoint, - accessToken: access.access_token, - expiresAtEpochMs: issuedAt + access.expires_in * 1_000, - dpopThumbprint: thumbprint, - }); - const socketUrl = yield* createDpopSocketUrl(token).pipe( - Effect.mapError(mapDpopSocketError), - ); - yield* tokenStore - .put(token) - .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: bootstrap.endpoint.httpBaseUrl, - socketUrl, - httpAuthorization: { - _tag: "Dpop" as const, - accessToken: token.accessToken, - }, - }; - }, - ); - - return RemoteEnvironmentAuthorization.of({ - authorizeBearer, - authorizeDpop: (input) => - authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), - }); - }), -); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 2a39edfd074..624ecf7f672 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -1,9 +1,28 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + type RemoteEnvironmentAuthError, + resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl, +} from "./remote.ts"; +import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as TokenStore from "./tokenStore.ts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as HttpClient from "effect/unstable/http/HttpClient"; -import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; +import type { PreparedHttpAuthorization } from "../connection/model.ts"; export interface RelayEnvironmentAuthorization { readonly environmentId: EnvironmentId; @@ -37,3 +56,247 @@ export class RemoteEnvironmentAuthorization extends Context.Service< }) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} + +const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; +const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; + +function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { + return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" + ? error + : mapRemoteEnvironmentError(error); +} + +const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( + httpBaseUrl: string, +) { + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); +}); + +export const make = Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + const presentation = yield* ClientCapabilities.ClientPresentation; + const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; + const httpClient = yield* HttpClient.HttpClient; + const cachedEndpointFailures = yield* Ref.make>(new Map()); + + const resetCachedEndpointFailures = (environmentId: string) => + Ref.update(cachedEndpointFailures, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + + const recordCachedEndpointFailure = (environmentId: string) => + Ref.modify(cachedEndpointFailures, (current) => { + const failureCount = (current.get(environmentId) ?? 0) + 1; + const next = new Map(current); + next.set(environmentId, failureCount); + return [failureCount, next] as const; + }); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) { + const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }; + }, + ); + + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( + function* (token: TokenStore.RemoteDpopAccessToken) { + const ticketProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the websocket authorization proof.", + }), + ), + ); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: token.endpoint.wsBaseUrl, + httpBaseUrl: token.endpoint.httpBaseUrl, + accessToken: token.accessToken, + dpopProof: ticketProof, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + ); + + const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["expectedEnvironmentId"]; + readonly obtainBootstrap: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["obtainBootstrap"]; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not load the environment authorization key.", + }), + ), + Effect.withSpan("environment.authorization.dpopKey.resolve"), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore + .get(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); + if ( + Option.isSome(cached) && + cached.value.environmentId === input.expectedEnvironmentId && + cached.value.dpopThumbprint === thumbprint && + cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "hit", + }); + const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); + if (Result.isSuccess(cachedSocket)) { + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + return { + environmentId: cached.value.environmentId, + label: cached.value.label, + httpBaseUrl: cached.value.endpoint.httpBaseUrl, + socketUrl: cachedSocket.success, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + } + if (cachedSocket.failure._tag === "ConnectionBlockedError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } + const mappedFailure = mapDpopSocketError(cachedSocket.failure); + if (mappedFailure._tag === "ConnectionTransientError") { + const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); + if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { + return yield* mappedFailure; + } + } + yield* tokenStore + .remove(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + } + + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "miss", + }); + const bootstrap = yield* input.obtainBootstrap; + const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.descriptor"), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the environment authorization proof.", + }), + ), + ); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + credential: bootstrap.credential, + dpopProof: bootstrapProof, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.accessToken.exchange"), + ); + const issuedAt = yield* Clock.currentTimeMillis; + const token = new TokenStore.RemoteDpopAccessToken({ + environmentId: descriptor.environmentId, + label: descriptor.label, + endpoint: bootstrap.endpoint, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError)); + yield* tokenStore + .put(token) + .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: token.accessToken, + }, + }; + }, + ); + + return RemoteEnvironmentAuthorization.of({ + authorizeBearer, + authorizeDpop: (input) => + authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + }); +}); + +export const layer = Layer.effect(RemoteEnvironmentAuthorization, make); diff --git a/packages/client-runtime/src/authorization/tokenStore.ts b/packages/client-runtime/src/authorization/tokenStore.ts index e00cc4cfdff..c490a22da13 100644 --- a/packages/client-runtime/src/authorization/tokenStore.ts +++ b/packages/client-runtime/src/authorization/tokenStore.ts @@ -2,6 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -28,3 +29,9 @@ export class RemoteDpopAccessTokenStore extends Context.Service< readonly remove: (environmentId: EnvironmentId) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} + +export const make = (service: RemoteDpopAccessTokenStore["Service"]) => + RemoteDpopAccessTokenStore.of(service); + +export const layer = (service: RemoteDpopAccessTokenStore["Service"]) => + Layer.succeed(RemoteDpopAccessTokenStore, make(service)); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 2a94ab70454..84f81153194 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -1,10 +1,7 @@ import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type { ConnectionAttemptError } from "./model.ts"; import { BearerConnectionTarget, PrimaryConnectionTarget, @@ -116,28 +113,3 @@ export function connectionRegistrationCatalogEntry( }; } } - -export class ConnectionProfileStore extends Context.Service< - ConnectionProfileStore, - { - readonly get: ( - connectionId: string, - ) => Effect.Effect, ConnectionAttemptError>; - readonly put: (profile: ConnectionProfile) => Effect.Effect; - readonly remove: (connectionId: string) => Effect.Effect; - } ->()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} - -export class ConnectionCredentialStore extends Context.Service< - ConnectionCredentialStore, - { - readonly get: ( - connectionId: string, - ) => Effect.Effect, ConnectionAttemptError>; - readonly put: ( - connectionId: string, - credential: ConnectionCredential, - ) => Effect.Effect; - readonly remove: (connectionId: string) => Effect.Effect; - } ->()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts index 44b38a3082e..6b40680ce35 100644 --- a/packages/client-runtime/src/connection/connectivity.ts +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Stream from "effect/Stream"; import type { NetworkStatus } from "./model.ts"; @@ -11,3 +12,8 @@ export class Connectivity extends Context.Service< readonly changes: Stream.Stream; } >()("@t3tools/client-runtime/connection/connectivity") {} + +export const make = (service: Connectivity["Service"]) => Connectivity.of(service); + +export const layer = (service: Connectivity["Service"]) => + Layer.succeed(Connectivity, make(service)); diff --git a/packages/client-runtime/src/connection/credentialStore.ts b/packages/client-runtime/src/connection/credentialStore.ts new file mode 100644 index 00000000000..0107bc91fb1 --- /dev/null +++ b/packages/client-runtime/src/connection/credentialStore.ts @@ -0,0 +1,27 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionCredential } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionCredentialStore extends Context.Service< + ConnectionCredentialStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: ( + connectionId: string, + credential: ConnectionCredential, + ) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/credentialStore/ConnectionCredentialStore") {} + +export const make = (service: ConnectionCredentialStore["Service"]) => + ConnectionCredentialStore.of(service); + +export const layer = (service: ConnectionCredentialStore["Service"]) => + Layer.succeed(ConnectionCredentialStore, make(service)); diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index c1a8f67a759..f29f913dd54 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -9,8 +9,8 @@ import type { ConnectionAttemptStage, PreparedConnection, } from "./model.ts"; -import { ConnectionResolver } from "./resolver.ts"; -import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as RpcSession from "../rpc/session.ts"; export type ConnectionDriverProgress = | { @@ -23,44 +23,42 @@ export type ConnectionDriverProgress = export interface EnvironmentConnectionLease { readonly prepared: PreparedConnection; - readonly session: RpcSession; + readonly session: RpcSession.RpcSession; } -export interface ConnectionDriverService { - readonly connect: ( - entry: ConnectionCatalogEntry, - reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, - ) => Effect.Effect; -} - -export class ConnectionDriver extends Context.Service()( - "@t3tools/client-runtime/connection/driver/ConnectionDriver", -) {} - -export const connectionDriverLayer = Layer.effect( +export class ConnectionDriver extends Context.Service< ConnectionDriver, - Effect.gen(function* () { - const resolver = yield* ConnectionResolver; - const sessions = yield* RpcSessionFactory; - - const connect = Effect.fn("ConnectionDriver.connect")(function* ( + { + readonly connect: ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, - ) { - const target = entry.target; - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": target.environmentId, - "connection.target.kind": target._tag, - }); - yield* reportProgress({ stage: "preparing" }); - const prepared = yield* resolver.prepare(entry); - yield* reportProgress({ stage: "opening", prepared }); - const session = yield* sessions.connect(prepared); - yield* reportProgress({ stage: "synchronizing", prepared }); - yield* session.ready; - return { prepared, session } satisfies EnvironmentConnectionLease; + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} + +export const make = Effect.gen(function* () { + const resolver = yield* ConnectionResolver.ConnectionResolver; + const sessions = yield* RpcSession.RpcSessionFactory; + + const connect = Effect.fn("ConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, }); + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* resolver.prepare(entry); + yield* reportProgress({ stage: "opening", prepared }); + const session = yield* sessions.connect(prepared); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + return ConnectionDriver.of({ connect }); +}); - return ConnectionDriver.of({ connect }); - }), -); +export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index ab5baec3364..5d9d361c06d 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -11,14 +11,14 @@ import { export function profileMissingError(connectionId: string): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${connectionId} is unavailable.`, + detail: `Connection profile ${connectionId} is unavailable.`, }); } export function credentialMissingError(connectionId: string): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "authentication", - message: `Connection credential ${connectionId} is unavailable.`, + detail: `Connection credential ${connectionId} is unavailable.`, }); } @@ -28,7 +28,7 @@ export function environmentMismatchError(input: { }): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "configuration", - message: `Connected environment ${input.actual} does not match ${input.expected}.`, + detail: `Connected environment ${input.actual} does not match ${input.expected}.`, }); } @@ -40,34 +40,34 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayAgentActivityPublishProofInvalidError": return new ConnectionBlockedError({ reason: "authentication", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentConnectNotAuthorizedError": case "RelayEnvironmentLinkProofInvalidError": return new ConnectionBlockedError({ reason: "permission", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentEndpointTimedOutError": return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentEndpointUnavailableError": case "RelayEnvironmentLinkUnavailableError": return new ConnectionTransientError({ reason: "endpoint-unavailable", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentLinkFailedError": case "RelayInternalError": return new ConnectionTransientError({ reason: "relay-unavailable", - message: error.message, + detail: error.message, traceId: error.traceId, }); } @@ -80,13 +80,13 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, ...(error.traceId ? { traceId: error.traceId } : {}), }); } return new ConnectionTransientError({ reason: "relay-unavailable", - message: error.message, + detail: error.message, ...(error.traceId ? { traceId: error.traceId } : {}), }); } @@ -98,43 +98,43 @@ export function mapRemoteEnvironmentError( case "EnvironmentAuthInvalidError": return new ConnectionBlockedError({ reason: "authentication", - message: "The environment credential is invalid.", + detail: "The environment credential is invalid.", traceId: error.traceId, }); case "EnvironmentScopeRequiredError": case "EnvironmentOperationForbiddenError": return new ConnectionBlockedError({ reason: "permission", - message: "The environment credential does not grant the required access.", + detail: "The environment credential does not grant the required access.", traceId: error.traceId, }); case "EnvironmentRequestInvalidError": return new ConnectionBlockedError({ reason: "configuration", - message: "The environment rejected the authentication request.", + detail: "The environment rejected the authentication request.", traceId: error.traceId, }); case "RemoteEnvironmentAuthTimeoutError": return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, }); case "RemoteEnvironmentAuthFetchError": return new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }); case "EnvironmentInternalError": return new ConnectionTransientError({ reason: "remote-unavailable", - message: "The environment could not authorize the connection.", + detail: "The environment could not authorize the connection.", traceId: error.traceId, }); case "RemoteEnvironmentAuthInvalidJsonError": case "RemoteEnvironmentAuthUndeclaredStatusError": return new ConnectionTransientError({ reason: "remote-unavailable", - message: error.message, + detail: error.message, }); } } diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index eb1db447bff..53a041bbf30 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -1,12 +1,33 @@ export * from "./catalog.ts"; -export * from "./connectivity.ts"; -export * from "./driver.ts"; +export * as Connectivity from "./connectivity.ts"; +export * as CredentialStore from "./credentialStore.ts"; +export { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; export * from "./errors.ts"; -export * from "./layer.ts"; +export * as Connection from "./layer.ts"; export * from "./model.ts"; -export * from "./onboarding.ts"; +export { + type BearerConnectionUpdateInput, + ConnectionOnboarding, + type PairingConnectionInput, + type SshConnectionInput, + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, + registerPairingConnection, + registerSshConnection, + updateBearerConnection, +} from "./onboarding.ts"; export * from "./presentation.ts"; -export * from "./registry.ts"; -export * from "./resolver.ts"; -export * from "./supervisor.ts"; -export * from "./wakeups.ts"; +export * as ProfileStore from "./profileStore.ts"; +export { + EnvironmentNotRegisteredError, + EnvironmentRegistry, + PlatformEnvironmentRemovalError, +} from "./registry.ts"; +export { ConnectionResolver } from "./resolver.ts"; +export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; +export * as Wakeups from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index c485c6c1b2c..a7485878e2d 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -2,37 +2,37 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import { connectionResolverLayer } from "./resolver.ts"; -import { connectionDriverLayer } from "./driver.ts"; -import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; -import { connectionOnboardingLayer } from "./onboarding.ts"; -import { PlatformConnectionSource } from "../platform/source.ts"; -import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; -import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; -import { rpcSessionFactoryLayer } from "../rpc/session.ts"; - -const resolverLayer = connectionResolverLayer.pipe( - Layer.provide(remoteEnvironmentAuthorizationLayer), +import * as ConnectionResolver from "./resolver.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as ConnectionOnboarding from "./onboarding.ts"; +import * as PlatformConnectionSource from "../platform/source.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as RpcSession from "../rpc/session.ts"; + +const resolverLayer = ConnectionResolver.layer.pipe( + Layer.provide(RemoteEnvironmentAuthorization.layer), ); -const driverLayer = connectionDriverLayer.pipe( - Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), +const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), ); -const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); +const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); -const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); +const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); const connectionServicesLayer = Layer.mergeAll( registryLayer, - relayEnvironmentDiscoveryLayer, + RelayEnvironmentDiscovery.layer, onboardingLayer, ); const connectionStartupLayer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; yield* registry.start; yield* platformSource.registrations.pipe( Stream.runForEach(registry.registerPlatform), @@ -41,6 +41,4 @@ const connectionStartupLayer = Layer.effectDiscard( }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), ); -export const connectionLayer = connectionStartupLayer.pipe( - Layer.provideMerge(connectionServicesLayer), -); +export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 5c1daf090e4..fbcb302ed13 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -57,44 +57,49 @@ export type ConnectionTargetKind = ConnectionTarget["_tag"]; export type NetworkStatus = "unknown" | "offline" | "online"; -export type ConnectionTransientReason = - | "network" - | "timeout" - | "transport" - | "endpoint-unavailable" - | "relay-unavailable" - | "remote-unavailable"; - -export type ConnectionBlockedReason = - | "authentication" - | "configuration" - | "permission" - | "unsupported"; +export const ConnectionTransientReason = Schema.Literals([ + "network", + "timeout", + "transport", + "endpoint-unavailable", + "relay-unavailable", + "remote-unavailable", +]); +export type ConnectionTransientReason = typeof ConnectionTransientReason.Type; + +export const ConnectionBlockedReason = Schema.Literals([ + "authentication", + "configuration", + "permission", + "unsupported", +]); +export type ConnectionBlockedReason = typeof ConnectionBlockedReason.Type; export class ConnectionTransientError extends Schema.TaggedErrorClass()( "ConnectionTransientError", { - reason: Schema.Literals([ - "network", - "timeout", - "transport", - "endpoint-unavailable", - "relay-unavailable", - "remote-unavailable", - ]), - message: Schema.String, + reason: ConnectionTransientReason, + detail: Schema.String, traceId: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + return this.detail; + } +} export class ConnectionBlockedError extends Schema.TaggedErrorClass()( "ConnectionBlockedError", { - reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), - message: Schema.String, + reason: ConnectionBlockedReason, + detail: Schema.String, traceId: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + return this.detail; + } +} export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 14f71b5859b..e76bcd50a2c 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -6,22 +6,22 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { HttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; -import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionRegistration, type ConnectionCatalogEntry, type ConnectionCredential, - ConnectionCredentialStore, SshConnectionProfile, SshConnectionRegistration, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { mapRemoteEnvironmentError } from "./errors.ts"; import { BearerConnectionTarget, @@ -29,8 +29,8 @@ import { SshConnectionTarget, type ConnectionAttemptError, } from "./model.ts"; -import type { ConnectionPersistenceError } from "../platform/persistence.ts"; -import { EnvironmentRegistry } from "./registry.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentRegistry from "./registry.ts"; export interface PairingConnectionInput { readonly pairingUrl?: string; @@ -54,13 +54,19 @@ export class ConnectionOnboarding extends Context.Service< { readonly registerPairing: ( input: PairingConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; readonly registerSsh: ( input: SshConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; readonly updateBearer: ( input: BearerConnectionUpdateInput, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} @@ -71,7 +77,7 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - message: cause instanceof Error ? cause.message : "The pairing details are invalid.", + detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", }), }); }, @@ -81,7 +87,7 @@ export const preparePairingRegistration = Effect.fn( "clientRuntime.connection.onboarding.preparePairingRegistration", )(function* (input: PairingConnectionInput) { const target = yield* resolvePairingTarget(input); - const presentation = yield* ClientPresentation; + const presentation = yield* ClientCapabilities.ClientPresentation; const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); @@ -116,7 +122,7 @@ export const registerPairingConnection = Effect.fn( "clientRuntime.connection.onboarding.registerPairingConnection", )(function* (input: PairingConnectionInput) { const registration = yield* preparePairingRegistration(input); - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(registration); return registration.target.environmentId; }); @@ -127,8 +133,8 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); export const updateBearerConnection = Effect.fn( "clientRuntime.connection.onboarding.updateBearerConnection", )(function* (input: BearerConnectionUpdateInput) { - const registry = yield* EnvironmentRegistry; - const credentials = yield* ConnectionCredentialStore; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); const credential = entry?.target._tag === "BearerConnectionTarget" @@ -159,7 +165,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( ) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Only saved bearer environments can be edited.", + detail: "Only saved bearer environments can be edited.", }); } @@ -167,7 +173,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( if (Option.isNone(credential) || !isBearerCredential(credential.value)) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The saved bearer credential is unavailable.", + detail: "The saved bearer credential is unavailable.", }); } @@ -175,7 +181,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( if (label === "") { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Environment label cannot be empty.", + detail: "Environment label cannot be empty.", }); } const httpBaseUrl = yield* Effect.try({ @@ -183,7 +189,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - message: cause instanceof Error ? cause.message : "The environment URL is invalid.", + detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", }), }); const connectionId = entry.target.connectionId; @@ -207,7 +213,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( export const prepareSshRegistration = Effect.fn( "clientRuntime.connection.onboarding.prepareSshRegistration", )(function* (input: SshConnectionInput) { - const gateway = yield* SshEnvironmentGateway; + const gateway = yield* ClientCapabilities.SshEnvironmentGateway; const provisioned = yield* gateway.provision(input.target); const connectionId = `ssh:${provisioned.environmentId}`; const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; @@ -231,37 +237,36 @@ export const registerSshConnection = Effect.fn( "clientRuntime.connection.onboarding.registerSshConnection", )(function* (input: SshConnectionInput) { const registration = yield* prepareSshRegistration(input); - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(registration); return registration.target.environmentId; }); -export const connectionOnboardingLayer = Layer.effect( - ConnectionOnboarding, - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; - const presentation = yield* ClientPresentation; - const httpClient = yield* HttpClient.HttpClient; - const ssh = yield* SshEnvironmentGateway; - const credentials = yield* ConnectionCredentialStore; +export const make = Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const presentation = yield* ClientCapabilities.ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; - return ConnectionOnboarding.of({ - registerPairing: (input) => - registerPairingConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(ClientPresentation, presentation), - Effect.provideService(HttpClient.HttpClient, httpClient), - ), - registerSsh: (input) => - registerSshConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(SshEnvironmentGateway, ssh), - ), - updateBearer: (input) => - updateBearerConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(ConnectionCredentialStore, credentials), - ), - }); - }), -); + return ConnectionOnboarding.of({ + registerPairing: (input) => + registerPairingConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + registerSsh: (input) => + registerSshConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh), + ), + updateBearer: (input) => + updateBearerConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), + ), + }); +}); + +export const layer = Layer.effect(ConnectionOnboarding, make); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index d28dd65c18a..354b003a2d4 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -67,7 +67,7 @@ describe("connection presentation", () => { attempt: 2, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Socket closed.", + detail: "Socket closed.", traceId: "trace-previous", }), }), @@ -85,7 +85,7 @@ describe("connection presentation", () => { retryAt: 1, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Disconnected.", + detail: "Disconnected.", traceId: "trace-1", }), }), @@ -106,7 +106,7 @@ describe("connection presentation", () => { attempt: 2, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Relay connection timed out.", + detail: "Relay connection timed out.", traceId: "trace-retry", }), }), diff --git a/packages/client-runtime/src/connection/profileStore.ts b/packages/client-runtime/src/connection/profileStore.ts new file mode 100644 index 00000000000..3432a7fe16e --- /dev/null +++ b/packages/client-runtime/src/connection/profileStore.ts @@ -0,0 +1,24 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionProfile } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionProfileStore extends Context.Service< + ConnectionProfileStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (profile: ConnectionProfile) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/profileStore/ConnectionProfileStore") {} + +export const make = (service: ConnectionProfileStore["Service"]) => + ConnectionProfileStore.of(service); + +export const layer = (service: ConnectionProfileStore["Service"]) => + Layer.succeed(ConnectionProfileStore, make(service)); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f0efe9b0549..885ba4cb781 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -14,23 +14,22 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { SshEnvironmentGateway } from "../platform/capabilities.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionRegistration, type ConnectionRegistration, - ConnectionCredentialStore, - ConnectionProfileStore, PrimaryConnectionRegistration, RelayConnectionRegistration, SshConnectionProfile, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { ConnectionDriver } from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import * as ConnectionDriver from "./driver.ts"; import { ConnectionTransientError, BearerConnectionTarget, @@ -41,17 +40,12 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import { - ConnectionPersistenceError, - ConnectionRegistrationStore, - ConnectionTargetStore, - EnvironmentCacheStore, - EnvironmentOwnedDataCleanup, -} from "../platform/persistence.ts"; -import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { EnvironmentSupervisor } from "./supervisor.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -137,10 +131,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; readonly beforeRegistrationRegister?: ( registration: ConnectionRegistration, - ) => Effect.Effect; + ) => Effect.Effect; readonly beforeRegistrationRemove?: ( target: ConnectionTarget, - ) => Effect.Effect; + ) => Effect.Effect; }, ) { const storedTargets = yield* Ref.make( @@ -160,7 +154,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( new Map([ [ SSH_CONNECTION.environmentId, - new RemoteDpopAccessToken({ + new TokenStore.RemoteDpopAccessToken({ environmentId: SSH_CONNECTION.environmentId, label: SSH_CONNECTION.label, endpoint: { @@ -177,10 +171,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ); const disconnectedSshTargets = yield* Ref.make>([]); - const targetStore = ConnectionTargetStore.of({ + const targetStore = Persistence.ConnectionTargetStore.of({ list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), }); - const registrationStore = ConnectionRegistrationStore.of({ + const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration) => Effect.gen(function* () { yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; @@ -239,7 +233,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); }), }); - const cacheStore = EnvironmentCacheStore.of({ + const cacheStore = Persistence.EnvironmentCacheStore.of({ loadShell: (environmentId) => Ref.get(shellCache).pipe( Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), @@ -264,16 +258,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ), ), }); - const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ + const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ clear: (environmentId) => Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), }); const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); - const connectivity = Connectivity.of({ + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ get: (connectionId) => Ref.update(profileReadCount, (count) => count + 1).pipe( Effect.andThen(Ref.get(storedProfiles)), @@ -292,7 +286,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ get: (connectionId) => Ref.get(storedCredentials).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), @@ -310,7 +304,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const tokenStore = RemoteDpopAccessTokenStore.of({ + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ get: (environmentId) => Ref.get(storedRemoteTokens).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), @@ -328,12 +322,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const sshGateway = SshEnvironmentGateway.of({ + const sshGateway = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die(new Error("SSH provisioning is not used.")), prepare: () => Effect.die(new Error("SSH preparation is not used.")), disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), }); - const driver = ConnectionDriver.of({ + const driver = ConnectionDriver.ConnectionDriver.of({ connect: (entry, reportProgress) => Effect.gen(function* () { const target = entry.target; @@ -350,12 +344,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( yield* Ref.update(sessions, (current) => [...current, { closed }]); const session = yield* Effect.acquireRelease( Effect.succeed({ - client: {} as RpcSession["client"], + client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), - } satisfies RpcSession), + } satisfies RpcSession.RpcSession), () => Ref.update(releasedSessions, (count) => count + 1), ); yield* reportProgress({ stage: "synchronizing", prepared }); @@ -364,21 +358,24 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }), }); - const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); - const layer = environmentRegistryLayer.pipe( + const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore); + const layer = EnvironmentRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ConnectionTargetStore, targetStore), - Layer.succeed(ConnectionRegistrationStore, registrationStore), - Layer.succeed(ConnectionProfileStore, profileStore), - Layer.succeed(ConnectionCredentialStore, credentialStore), - Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), - Layer.succeed(SshEnvironmentGateway, sshGateway), - Layer.succeed(Connectivity, connectivity), - Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), - Layer.succeed(ConnectionDriver, driver), + Layer.succeed(Persistence.ConnectionTargetStore, targetStore), + Layer.succeed(Persistence.ConnectionRegistrationStore, registrationStore), + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway), + Layer.succeed(Connectivity.Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), + Layer.succeed(ConnectionDriver.ConnectionDriver, driver), cacheLayer, - Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), + Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, ownedDataCleanup), ), ), ); @@ -401,7 +398,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); function awaitConnectionState( - registry: EnvironmentRegistry["Service"], + registry: EnvironmentRegistry.EnvironmentRegistry["Service"], environmentId: EnvironmentId, predicate: (state: SupervisorConnectionState) => boolean, ) { @@ -422,7 +419,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const entry = (yield* SubscriptionRef.get(registry.entries)).get( SSH_CONNECTION.environmentId, ); @@ -438,7 +435,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const offline = yield* Effect.forkChild( SubscriptionRef.changes(registry.networkStatus).pipe( Stream.filter((status) => status === "offline"), @@ -471,7 +468,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const start = yield* Effect.forkChild(registry.start); yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); @@ -487,7 +484,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -499,7 +496,7 @@ describe("EnvironmentRegistry", () => { .runStream( TARGET.environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => Stream.concat( Stream.fromEffect(SubscriptionRef.get(supervisor.state)), @@ -527,7 +524,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -554,7 +551,7 @@ describe("EnvironmentRegistry", () => { active!.closed, new ConnectionTransientError({ reason: "transport", - message: "Disconnected.", + detail: "Disconnected.", }), ); yield* Fiber.join(retryFiber); @@ -578,7 +575,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); yield* awaitConnectionState( registry, @@ -603,7 +600,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([RELAY_TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const firstObserved = yield* Deferred.make(); const secondObserved = yield* Deferred.make(); const labels = yield* Ref.make>([]); @@ -619,7 +616,7 @@ describe("EnvironmentRegistry", () => { .followStream( RELAY_TARGET.environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), ), @@ -655,7 +652,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.retryNow(EnvironmentId.make("removed-environment")); }).pipe(Effect.provide(harness.layer), Effect.scoped); }), @@ -670,7 +667,7 @@ describe("EnvironmentRegistry", () => { ); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.removeRelayEnvironments(); const targets = yield* Ref.get(harness.storedTargets); @@ -695,7 +692,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([RELAY_TARGET], [], [], { beforeRegistrationRemove: () => Effect.fail( - new ConnectionPersistenceError({ + new Persistence.ConnectionPersistenceError({ operation: "remove-connection", message: "Storage is unavailable.", }), @@ -703,7 +700,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -730,7 +727,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register( new BearerConnectionRegistration({ target: BEARER_TARGET, @@ -760,7 +757,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); yield* awaitConnectionState( registry, @@ -791,7 +788,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([shadowedTarget]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); expect( @@ -825,7 +822,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const persistedRegistration = yield* registry .register(new RelayConnectionRegistration({ target: shadowedTarget })) .pipe(Effect.forkChild({ startImmediately: true })); @@ -864,7 +861,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -894,7 +891,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const registration = new PrimaryConnectionRegistration({ target: TARGET }); yield* registry.registerPlatform(registration); yield* awaitConnectionState( @@ -924,7 +921,7 @@ describe("EnvironmentRegistry", () => { ); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* registry.remove(SSH_CONNECTION.environmentId); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 7560d06f50f..3a95185d835 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -12,120 +12,124 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { type ConnectionCatalogEntry, type ConnectionRegistration, - ConnectionProfileStore, type PrimaryConnectionRegistration, SshConnectionProfile, connectionRegistrationCatalogEntry, } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as Connectivity from "./connectivity.ts"; import type { ConnectionAttemptError, ConnectionTarget, NetworkStatus, SupervisorConnectionState, } from "./model.ts"; -import { - type ConnectionPersistenceError, - ConnectionRegistrationStore, - ConnectionTargetStore, - EnvironmentCacheStore, - EnvironmentOwnedDataCleanup, -} from "../platform/persistence.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, - makeEnvironmentSupervisor, -} from "./supervisor.ts"; -import { ConnectionDriver } from "./driver.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const isSshConnectionProfile = Schema.is(SshConnectionProfile); export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass()( "EnvironmentNotRegisteredError", { - environmentId: Schema.String, - message: Schema.String, + environmentId: EnvironmentId, }, -) {} +) { + override get message(): string { + return `Environment ${this.environmentId} is not registered.`; + } +} export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( "PlatformEnvironmentRemovalError", { - environmentId: Schema.String, - message: Schema.String, - }, -) {} - -export interface EnvironmentRegistryService { - readonly entries: SubscriptionRef.SubscriptionRef< - ReadonlyMap - >; - readonly networkStatus: SubscriptionRef.SubscriptionRef; - readonly start: Effect.Effect; - readonly register: ( - registration: ConnectionRegistration, - ) => Effect.Effect; - readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; - readonly remove: ( environmentId: EnvironmentId, - ) => Effect.Effect< - void, - | ConnectionPersistenceError - | ConnectionAttemptError - | EnvironmentNotRegisteredError - | PlatformEnvironmentRemovalError - >; - readonly removeRelayEnvironments: () => Effect.Effect< - void, - ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError - >; - readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; - readonly state: ( - environmentId: EnvironmentId, - ) => Effect.Effect; - readonly stateChanges: ( - environmentId: EnvironmentId, - ) => Stream.Stream; - readonly run: ( - environmentId: EnvironmentId, - effect: Effect.Effect, - ) => Effect.Effect>; - readonly runStream: ( - environmentId: EnvironmentId, - stream: Stream.Stream, - ) => Stream.Stream>; - readonly followStream: ( - environmentId: EnvironmentId, - stream: Stream.Stream, - ) => Stream.Stream>; + }, +) { + override get message(): string { + return `Platform-managed environment ${this.environmentId} cannot be removed.`; + } } export class EnvironmentRegistry extends Context.Service< EnvironmentRegistry, - EnvironmentRegistryService + { + readonly entries: SubscriptionRef.SubscriptionRef< + ReadonlyMap + >; + readonly networkStatus: SubscriptionRef.SubscriptionRef; + readonly start: Effect.Effect; + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; + readonly removeRelayEnvironments: () => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | PlatformEnvironmentRemovalError + >; + readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + readonly state: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly stateChanges: ( + environmentId: EnvironmentId, + ) => Stream.Stream; + readonly run: ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ) => Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly runStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly followStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; + } >()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} interface EnvironmentServiceScope { readonly entry: ConnectionCatalogEntry; - readonly supervisor: EnvironmentSupervisorService; + readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; readonly scope: Scope.Closeable; } -const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { - const storage = yield* ConnectionTargetStore; - const registrations = yield* ConnectionRegistrationStore; - const cache = yield* EnvironmentCacheStore; - const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; - const profiles = yield* ConnectionProfileStore; - const connectivity = yield* Connectivity; - const driver = yield* ConnectionDriver; - const wakeups = yield* ConnectionWakeups; - const ssh = yield* SshEnvironmentGateway; +export const make = Effect.gen(function* () { + const storage = yield* Persistence.ConnectionTargetStore; + const registrations = yield* Persistence.ConnectionRegistrationStore; + const cache = yield* Persistence.EnvironmentCacheStore; + const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; const initialEntries = new Map( yield* Effect.forEach( @@ -215,7 +219,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* if (entry === undefined) { return yield* new EnvironmentNotRegisteredError({ environmentId, - message: `Environment ${environmentId} is not registered.`, }); } return entry; @@ -241,12 +244,12 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* Effect.gen(function* () { const environmentId = entry.target.environmentId; const scope = yield* Scope.make(); - const supervisor = yield* makeEnvironmentSupervisor(entry, { + const supervisor = yield* EnvironmentSupervisor.make(entry, { initiallyDesired: false, }).pipe( - Effect.provideService(Connectivity, connectivity), - Effect.provideService(ConnectionDriver, driver), - Effect.provideService(ConnectionWakeups, wakeups), + Effect.provideService(Connectivity.Connectivity, connectivity), + Effect.provideService(ConnectionDriver.ConnectionDriver, driver), + Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups), Scope.provide(scope), Effect.onError(() => Scope.close(scope, Exit.void)), ); @@ -280,28 +283,30 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* ); }); - const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < - A, - E, - R, - >(environmentId: EnvironmentId, effect: Effect.Effect) { - const supervisor = yield* acquireSupervisor(environmentId); - return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); - }); + const run: EnvironmentRegistry["Service"]["run"] = Effect.fn("EnvironmentRegistry.run")( + function* (environmentId: EnvironmentId, effect: Effect.Effect) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + }, + ); - const runStream: EnvironmentRegistryService["runStream"] = ( + const runStream: EnvironmentRegistry["Service"]["runStream"] = ( environmentId: EnvironmentId, stream: Stream.Stream, ) => Stream.unwrap( acquireSupervisor(environmentId).pipe( Effect.map((supervisor) => - Stream.provideService(stream, EnvironmentSupervisor, supervisor), + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ), ), ); - const followStream: EnvironmentRegistryService["followStream"] = ( + const followStream: EnvironmentRegistry["Service"]["followStream"] = ( environmentId: EnvironmentId, stream: Stream.Stream, ) => @@ -320,7 +325,11 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* Effect.match({ onFailure: () => Stream.empty, onSuccess: (supervisor) => - Stream.provideService(stream, EnvironmentSupervisor, supervisor), + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ), }), ), ), @@ -443,7 +452,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return yield* new PlatformEnvironmentRemovalError({ environmentId, - message: "Platform-managed environments cannot be removed.", }); } const target = (yield* getEntry(environmentId)).target; @@ -532,7 +540,7 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* followStream( environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), ), ), @@ -570,7 +578,4 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* }); }); -export const environmentRegistryLayer = Layer.effect( - EnvironmentRegistry, - makeEnvironmentRegistry(), -); +export const layer = Layer.effect(EnvironmentRegistry, make); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 5c1ed83ec6b..7d165b22ea2 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -8,30 +8,19 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Tracer from "effect/Tracer"; -import { - ManagedRelayClient, - ManagedRelayClientError, - ManagedRelayRequestTimeoutError, -} from "../relay/managedRelay.ts"; -import { ConnectionResolver } from "./resolver.ts"; -import { connectionResolverLayer } from "./resolver.ts"; -import { - CloudSession, - PrimaryEnvironmentAuth, - RelayDeviceIdentity, - SshEnvironmentGateway, -} from "../platform/capabilities.ts"; -import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import { BearerConnectionCredential, BearerConnectionProfile, type ConnectionCatalogEntry, - ConnectionCredentialStore, - ConnectionProfileStore, SshConnectionProfile, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { BearerConnectionTarget, ConnectionTransientError, @@ -40,6 +29,7 @@ import { SshConnectionTarget, type ConnectionTarget, } from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -79,8 +69,10 @@ function collectingTracer(spans: Array): Tracer.Tracer { }); } -function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { - return ManagedRelayClient.of({ +function relayClient( + connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"], +) { + return ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => unsupported("listEnvironments"), listDevices: () => unsupported("listDevices"), @@ -99,29 +91,29 @@ function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectE const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { readonly profiles?: ReadonlyArray; readonly credentials?: ReadonlyArray; - readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; - readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; - readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; + readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; + readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; readonly primaryBearerToken?: string; - readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; + readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), ); const credentials = new Map(options?.credentials ?? []); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), put: (connectionId, credential) => Effect.sync(() => void credentials.set(connectionId, credential)), remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), }); - const remote = RemoteEnvironmentAuthorization.of({ + const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ authorizeBearer: options?.authorizeBearer ?? ((input) => @@ -151,7 +143,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }), )), }); - const ssh = SshEnvironmentGateway.of({ + const ssh = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die("unused"), prepare: options?.prepareSsh ?? @@ -169,23 +161,28 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); const dependencies = Layer.mergeAll( - Layer.succeed(ConnectionProfileStore, profileStore), - Layer.succeed(ConnectionCredentialStore, credentialStore), - Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), Layer.succeed( - PrimaryEnvironmentAuth, - PrimaryEnvironmentAuth.of({ + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }), + ), + Layer.succeed( + ClientCapabilities.PrimaryEnvironmentAuth, + ClientCapabilities.PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), }), ), Layer.succeed( - RelayDeviceIdentity, - RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), + ClientCapabilities.RelayDeviceIdentity, + ClientCapabilities.RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.some("device-1")), + }), ), - Layer.succeed(RemoteEnvironmentAuthorization, remote), - Layer.succeed(SshEnvironmentGateway, ssh), + Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), Layer.succeed( - ManagedRelayClient, + ManagedRelay.ManagedRelayClient, relayClient( options?.connectEnvironment ?? ((input) => @@ -199,14 +196,14 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o ), ); - return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); + return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies))); }); describe("ConnectionResolver", () => { it.effect("prepares a primary environment without remote capabilities", () => Effect.gen(function* () { const brokerLayer = yield* makeDependencies(); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const target = new PrimaryConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Primary", @@ -244,7 +241,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const target = new PrimaryConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Primary", @@ -292,7 +289,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect( (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, @@ -349,7 +346,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); expect(yield* Ref.get(relayInputs)).toEqual([ @@ -387,7 +384,7 @@ describe("ConnectionResolver", () => { Effect.withSpan("test.remote.authorizeDpop"), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); yield* broker .prepare(catalogEntry(target)) @@ -431,7 +428,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect( (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, @@ -449,15 +446,15 @@ describe("ConnectionResolver", () => { const brokerLayer = yield* makeDependencies({ connectEnvironment: () => Effect.fail( - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay timed out.", - cause: new ManagedRelayRequestTimeoutError({ + cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ message: "Relay timed out.", }), }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); expect(error).toBeInstanceOf(ConnectionTransientError); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index ae18535e4d0..c219bde092c 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -6,22 +6,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; -import { ManagedRelayClient } from "../relay/managedRelay.ts"; -import { - CloudSession, - PrimaryEnvironmentAuth, - RelayDeviceIdentity, - SshEnvironmentGateway, -} from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, BearerConnectionProfile, type ConnectionCatalogEntry, - ConnectionCredentialStore, - ConnectionProfileStore, SshConnectionProfile, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { credentialMissingError, environmentMismatchError, @@ -37,6 +31,7 @@ import type { SshConnectionTarget, } from "./model.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; export class ConnectionResolver extends Context.Service< ConnectionResolver, @@ -60,8 +55,8 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string { } const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { - const auth = yield* PrimaryEnvironmentAuth; - const remote = yield* RemoteEnvironmentAuthorization; + const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.primary")(function* ( target: PrimaryConnectionTarget, @@ -92,8 +87,8 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary }); const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { - const credentials = yield* ConnectionCredentialStore; - const remote = yield* RemoteEnvironmentAuthorization; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, @@ -106,7 +101,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") if (!isBearerProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${target.connectionId} is not a bearer connection.`, + detail: `Connection profile ${target.connectionId} is not a bearer connection.`, }); } if (profile.environmentId !== target.environmentId) { @@ -144,10 +139,10 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }); const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { - const relay = yield* ManagedRelayClient; - const session = yield* CloudSession; - const identity = yield* RelayDeviceIdentity; - const remote = yield* RemoteEnvironmentAuthorization; + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const identity = yield* ClientCapabilities.RelayDeviceIdentity; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fnUntraced( function* (target: RelayConnectionTarget) { @@ -192,9 +187,9 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f }); const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { - const profiles = yield* ConnectionProfileStore; - const ssh = yield* SshEnvironmentGateway; - const remote = yield* RemoteEnvironmentAuthorization; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, @@ -207,7 +202,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct if (!isSshProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${target.connectionId} is not an SSH connection.`, + detail: `Connection profile ${target.connectionId} is not an SSH connection.`, }); } if (profile.environmentId !== target.environmentId) { @@ -246,34 +241,33 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct }); }); -export const connectionResolverLayer = Layer.effect( - ConnectionResolver, - Effect.gen(function* () { - const primary = yield* makePrimaryBroker(); - const bearer = yield* makeBearerBroker(); - const relay = yield* makeRelayBroker(); - const ssh = yield* makeSshBroker(); +export const make = Effect.gen(function* () { + const primary = yield* makePrimaryBroker(); + const bearer = yield* makeBearerBroker(); + const relay = yield* makeRelayBroker(); + const ssh = yield* makeSshBroker(); - const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( - entry: ConnectionCatalogEntry, - ) { - const target: ConnectionTarget = entry.target; - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": target.environmentId, - "connection.target.kind": target._tag, - }); - switch (target._tag) { - case "PrimaryConnectionTarget": - return yield* primary(target); - case "BearerConnectionTarget": - return yield* bearer({ ...entry, target }); - case "RelayConnectionTarget": - return yield* relay(target); - case "SshConnectionTarget": - return yield* ssh({ ...entry, target }); - } + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const target: ConnectionTarget = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, }); + switch (target._tag) { + case "PrimaryConnectionTarget": + return yield* primary(target); + case "BearerConnectionTarget": + return yield* bearer({ ...entry, target }); + case "RelayConnectionTarget": + return yield* relay(target); + case "SshConnectionTarget": + return yield* ssh({ ...entry, target }); + } + }); + + return ConnectionResolver.of({ prepare }); +}); - return ConnectionResolver.of({ prepare }); - }), -); +export const layer = Layer.effect(ConnectionResolver, make); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 1ebd2812c92..eadeceacc2c 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -13,12 +13,8 @@ import * as Tracer from "effect/Tracer"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -30,9 +26,9 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { makeEnvironmentSupervisor } from "./supervisor.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -70,14 +66,14 @@ const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; function transient(message = "Connection failed.") { return new ConnectionTransientError({ reason: "transport", - message, + detail: message, }); } function blocked(message = "Authentication required.") { return new ConnectionBlockedError({ reason: "authentication", - message, + detail: message, }); } @@ -137,7 +133,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ReadonlyArray> >([]); - const connectivity = Connectivity.of({ + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); @@ -152,7 +148,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: const connect = Effect.fn("TestConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, - reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect, ) { const target = entry.target; yield* reportProgress({ stage: "preparing" }); @@ -170,27 +166,30 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), - } satisfies RpcSession), + } satisfies RpcSession.RpcSession), () => Ref.update(releaseCount, (count) => count + 1), ); yield* reportProgress({ stage: "synchronizing", prepared }); yield* session.ready; - return { prepared, session } satisfies EnvironmentConnectionLease; + return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease; }); const dependencies = Layer.mergeAll( - Layer.succeed(Connectivity, connectivity), + Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: SubscriptionRef.changes(wakeups).pipe( Stream.drop(1), Stream.map((event) => event.reason), ), }), ), - Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), + Layer.succeed( + ConnectionDriver.ConnectionDriver, + ConnectionDriver.ConnectionDriver.of({ connect }), + ), ); return { @@ -235,7 +234,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe( Effect.provide(harness.dependencies), @@ -263,7 +262,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not attempt a connection until it is desired", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( Effect.provide(harness.dependencies), ); @@ -275,7 +274,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not let the initial connect signal cancel the first attempt", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( Effect.provide(harness.dependencies), ); @@ -290,7 +289,7 @@ describe("EnvironmentSupervisor", () => { it.effect("waits while offline and connects immediately when the network returns", () => Effect.gen(function* () { const harness = yield* makeHarness({ networkStatus: "offline" }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -317,7 +316,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ prepare: () => Effect.fail(transient()), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -345,7 +344,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -378,7 +377,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ ready: () => Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -410,7 +409,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ prepare: () => Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -442,7 +441,7 @@ describe("EnvironmentSupervisor", () => { ? Effect.die(new Error("Native transport defect.")) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -470,7 +469,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -488,7 +487,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -505,7 +504,7 @@ describe("EnvironmentSupervisor", () => { it.effect("releases a live session while offline and starts a new generation when online", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -534,7 +533,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -553,7 +552,7 @@ describe("EnvironmentSupervisor", () => { prepare: () => Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -591,7 +590,7 @@ describe("EnvironmentSupervisor", () => { it.effect("treats an involuntary session close as transient and reconnects", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -617,7 +616,7 @@ describe("EnvironmentSupervisor", () => { it.effect("keeps escalating backoff when a newly opened session flaps", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -663,7 +662,7 @@ describe("EnvironmentSupervisor", () => { Effect.andThen(Deferred.succeed(probeCalled, undefined)), ), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -684,7 +683,7 @@ describe("EnvironmentSupervisor", () => { probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -707,7 +706,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -732,7 +731,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -749,7 +748,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not churn a healthy session when credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -766,7 +765,7 @@ describe("EnvironmentSupervisor", () => { it.effect("releases and reconnects a relay session when credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -791,7 +790,7 @@ describe("EnvironmentSupervisor", () => { ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -807,7 +806,7 @@ describe("EnvironmentSupervisor", () => { it.effect("explicit disconnect releases the session and returns to available", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -824,7 +823,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 56ebe0efaf4..99889916a9a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -15,12 +15,8 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Tracer from "effect/Tracer"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; import { type ConnectionAttemptError, type ConnectionTarget, @@ -29,8 +25,8 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; @@ -47,7 +43,7 @@ type SupervisorSignal = | { readonly _tag: "DisconnectRequested" } | { readonly _tag: "RetryRequested" } | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } - | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; interface PendingRetryTrace { readonly previousAttempt: Tracer.Span; @@ -80,7 +76,7 @@ type EstablishmentEvent = readonly exit: Exit.Exit< { readonly attemptSpan: Option.Option; - readonly lease: EnvironmentConnectionLease; + readonly lease: ConnectionDriver.EnvironmentConnectionLease; }, TracedAttemptFailure >; @@ -102,16 +98,6 @@ export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; } -export interface EnvironmentSupervisorService { - readonly target: ConnectionTarget; - readonly state: SubscriptionRef.SubscriptionRef; - readonly session: SubscriptionRef.SubscriptionRef>; - readonly prepared: SubscriptionRef.SubscriptionRef>; - readonly connect: Effect.Effect; - readonly disconnect: Effect.Effect; - readonly retryNow: Effect.Effect; -} - function retryDelayMs(failureCount: number): number { return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; } @@ -199,7 +185,7 @@ function failureFromExit( failure: { error: new ConnectionTransientError({ reason: "transport", - message: `${target.label} connection failed unexpectedly.`, + detail: `${target.label} connection failed unexpectedly.`, }), attemptSpan: Option.none(), }, @@ -208,34 +194,34 @@ function failureFromExit( export class EnvironmentSupervisor extends Context.Service< EnvironmentSupervisor, - EnvironmentSupervisorService ->()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { - static layer( - entry: ConnectionCatalogEntry, - options?: EnvironmentSupervisorOptions, - ): Layer.Layer< - EnvironmentSupervisor, - never, - Connectivity | ConnectionDriver | ConnectionWakeups - > { - return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); + { + readonly target: ConnectionTarget; + readonly state: SubscriptionRef.SubscriptionRef; + readonly session: SubscriptionRef.SubscriptionRef>; + readonly prepared: SubscriptionRef.SubscriptionRef>; + readonly connect: Effect.Effect; + readonly disconnect: Effect.Effect; + readonly retryNow: Effect.Effect; } -} +>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} -export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( +export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( entry: ConnectionCatalogEntry, options?: EnvironmentSupervisorOptions, ): Effect.fn.Return< - EnvironmentSupervisorService, + EnvironmentSupervisor["Service"], never, - Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | Scope.Scope + | ConnectionWakeups.ConnectionWakeups > { const target = entry.target; yield* annotateTarget(target); - const connectivity = yield* Connectivity; - const driver = yield* ConnectionDriver; - const wakeups = yield* ConnectionWakeups; + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, network: yield* connectivity.status, @@ -249,7 +235,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") ? offlineState(initialIntent, 0, 0, null) : connectingState(initialIntent, 0, 1, null), ); - const session = yield* SubscriptionRef.make>(Option.none()); + const session = yield* SubscriptionRef.make>(Option.none()); const prepared = yield* SubscriptionRef.make>(Option.none()); const clearLease = Effect.all( @@ -280,7 +266,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") attempt: number, generation: number, lastFailure: ConnectionAttemptError | null, - progress: ConnectionDriverProgress, + progress: ConnectionDriver.ConnectionDriverProgress, ) { if ("prepared" in progress) { yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); @@ -301,7 +287,11 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") }); const traceRelayEstablishment = ( - effect: Effect.Effect, + effect: Effect.Effect< + ConnectionDriver.EnvironmentConnectionLease, + ConnectionAttemptError, + Scope.Scope + >, attempt: number, generation: number, pendingRetry: Option.Option, @@ -392,7 +382,9 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") } }); - const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { + const monitorConnectedLease = Effect.fnUntraced(function* ( + lease: ConnectionDriver.EnvironmentConnectionLease, + ) { for (;;) { const next = yield* Queue.take(signals); switch (next._tag) { @@ -417,7 +409,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") Effect.fail( new ConnectionTransientError({ reason: "timeout", - message: `${target.label} did not respond to a connection health check.`, + detail: `${target.label} did not respond to a connection health check.`, }), ), }), @@ -499,7 +491,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") failure: { error: new ConnectionTransientError({ reason: "timeout", - message: `${target.label} did not respond during connection setup.`, + detail: `${target.label} did not respond during connection setup.`, }), attemptSpan: Option.none(), }, @@ -722,3 +714,14 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") retryNow, }); }); + +export const layer = ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Layer.Layer< + EnvironmentSupervisor, + never, + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | ConnectionWakeups.ConnectionWakeups +> => Layer.effect(EnvironmentSupervisor, make(entry, options)); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 93449077838..107c5983e02 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type * as Stream from "effect/Stream"; export type ConnectionWakeup = "application-active" | "credentials-changed"; @@ -9,3 +10,8 @@ export class ConnectionWakeups extends Context.Service< readonly changes: Stream.Stream; } >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} + +export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); + +export const layer = (service: ConnectionWakeups["Service"]) => + Layer.succeed(ConnectionWakeups, make(service)); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index e7e59dd85d4..5cc3f0c1a86 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -18,11 +18,8 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; @@ -51,14 +48,14 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct return { sequence: dispatched.length }; }), } as unknown as WsRpcProtocolClient; - const session: RpcSession = { + const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, ready: Effect.void, probe: Effect.void, closed: Effect.never, }; - return EnvironmentSupervisor.of({ + return EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), session: yield* SubscriptionRef.make(Option.some(session)), @@ -66,7 +63,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); }); describe("environment commands", () => { @@ -80,7 +77,7 @@ describe("environment commands", () => { title: "Project", workspaceRoot: "/workspace/project", createdAt: "2026-06-06T00:00:00.000Z", - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(result).toEqual({ sequence: 1 }); expect(dispatched).toEqual([ @@ -105,7 +102,7 @@ describe("environment commands", () => { commandId: CommandId.make("queued-command"), threadId: ThreadId.make("thread-1"), createdAt: "2026-06-06T00:01:00.000Z", - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(dispatched).toEqual([ { @@ -126,7 +123,7 @@ describe("environment commands", () => { yield* archiveThread({ commandId: CommandId.make("archive-command"), threadId: ThreadId.make("thread-1"), - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(dispatched).toEqual([ { diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 359594033f5..8ad6b81e12b 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; import { BearerConnectionCredential, BearerConnectionProfile, @@ -38,7 +38,7 @@ const BEARER_PROFILE = new BearerConnectionProfile({ const BEARER_CREDENTIAL = new BearerConnectionCredential({ token: "bearer-token", }); -const REMOTE_TOKEN = new RemoteDpopAccessToken({ +const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: "Remote", endpoint: { diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 4eafb298e5e..0ba55dfa2fb 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -6,7 +6,7 @@ import { ConnectionProfile, } from "../connection/catalog.ts"; import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; -import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; export const StoredConnectionCredential = Schema.Struct({ connectionId: Schema.String, @@ -19,7 +19,7 @@ export const ConnectionCatalogDocument = Schema.Struct({ targets: Schema.Array(PersistedConnectionTarget), profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), - remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), + remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), }); export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index c1703657162..e05302195db 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -13,17 +13,12 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { - ManagedRelayClient, - ManagedRelayClientError, - ManagedRelayRequestTimeoutError, - type ManagedRelayClientShape, -} from "./managedRelay.ts"; -import { CloudSession } from "../platform/capabilities.ts"; -import { Connectivity } from "../connection/connectivity.ts"; +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; -import { ConnectionWakeups } from "../connection/wakeups.ts"; -import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; +import * as RelayEnvironmentDiscovery from "./discovery.ts"; const environments = [ { @@ -63,7 +58,7 @@ function status( const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { const networkStatus = yield* SubscriptionRef.make("online"); const listCalls = yield* Ref.make(0); - const listFailure = yield* Ref.make(null); + const listFailure = yield* Ref.make(null); const secondListCall = yield* Deferred.make(); const clerkToken = yield* Ref.make("clerk-token"); const wakeups = yield* SubscriptionRef.make<{ @@ -74,10 +69,16 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { reason: "application-active", }); const statusRequests = yield* Ref.make( - new Map>(), + new Map< + string, + Deferred.Deferred + >(), ); for (const environment of environments) { - const request = yield* Deferred.make(); + const request = yield* Deferred.make< + RelayEnvironmentStatusResponse, + ManagedRelay.ManagedRelayClientError + >(); yield* Ref.update(statusRequests, (current) => { const next = new Map(current); next.set(environment.environmentId, request); @@ -85,7 +86,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { }); } - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.gen(function* () { @@ -112,25 +113,25 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), resetTokenCache: Effect.void, - } satisfies ManagedRelayClientShape); - const connectivity = Connectivity.of({ + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); - const layer = relayEnvironmentDiscoveryLayer.pipe( + const layer = RelayEnvironmentDiscovery.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ManagedRelayClient, client), + Layer.succeed(ManagedRelay.ManagedRelayClient, client), Layer.succeed( - CloudSession, - CloudSession.of({ + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ clerkToken: Ref.get(clerkToken).pipe( Effect.flatMap((token) => token === null ? Effect.fail( new ConnectionBlockedError({ reason: "authentication", - message: "Signed out.", + detail: "Signed out.", }), ) : Effect.succeed(token), @@ -138,10 +139,10 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { ), }), ), - Layer.succeed(Connectivity, connectivity), + Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: SubscriptionRef.changes(wakeups).pipe( Stream.drop(1), Stream.map((event) => event.reason), @@ -173,7 +174,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const refreshFiber = yield* Effect.forkChild(discovery.refresh); const checking = yield* SubscriptionRef.changes(discovery.state).pipe( @@ -224,7 +225,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const requests = yield* Ref.get(harness.statusRequests); for (const environment of environments) { yield* Deferred.succeed( @@ -253,13 +254,13 @@ describe("RelayEnvironmentDiscovery", () => { it.effect("publishes listing failures without rejecting the refresh command", () => Effect.gen(function* () { const networkStatus = yield* SubscriptionRef.make("online"); - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.fail( - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay environment listing timed out.", - cause: new ManagedRelayRequestTimeoutError({ + cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ message: "Relay environment listing timed out.", }), }), @@ -274,25 +275,28 @@ describe("RelayEnvironmentDiscovery", () => { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), resetTokenCache: Effect.void, - } satisfies ManagedRelayClientShape); - const layer = relayEnvironmentDiscoveryLayer.pipe( + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const layer = RelayEnvironmentDiscovery.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ManagedRelayClient, client), - Layer.succeed(CloudSession, { + Layer.succeed(ManagedRelay.ManagedRelayClient, client), + Layer.succeed(ClientCapabilities.CloudSession, { clerkToken: Effect.succeed("clerk-token"), }), - Layer.succeed(Connectivity, { + Layer.succeed(Connectivity.Connectivity, { status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }), - Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), ), ), ); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; yield* discovery.refresh; const state = yield* SubscriptionRef.get(discovery.state); @@ -310,7 +314,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const requests = yield* Ref.get(harness.statusRequests); for (const environment of environments) { yield* Deferred.succeed( @@ -323,7 +327,7 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay environment listing failed.", }), ); @@ -340,7 +344,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const refreshFiber = yield* Effect.forkChild(discovery.refresh); yield* SubscriptionRef.changes(discovery.state).pipe( Stream.filter((state) => state.environments.size === environments.length), diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index c763aef9f68..8cbadea1ca5 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -16,12 +16,12 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { ManagedRelayClient } from "./managedRelay.ts"; -import { CloudSession } from "../platform/capabilities.ts"; -import { Connectivity } from "../connection/connectivity.ts"; +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; import { mapManagedRelayError } from "../connection/errors.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; -import { ConnectionWakeups } from "../connection/wakeups.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; @@ -39,14 +39,12 @@ export interface RelayEnvironmentDiscoveryState { readonly error: Option.Option; } -export interface RelayEnvironmentDiscoveryService { - readonly state: SubscriptionRef.SubscriptionRef; - readonly refresh: Effect.Effect; -} - export class RelayEnvironmentDiscovery extends Context.Service< RelayEnvironmentDiscovery, - RelayEnvironmentDiscoveryService + { + readonly state: SubscriptionRef.SubscriptionRef; + readonly refresh: Effect.Effect; + } >()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { @@ -64,7 +62,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned status for a different environment.", + detail: "Relay returned status for a different environment.", }), ); } @@ -76,7 +74,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned status for a different environment endpoint.", + detail: "Relay returned status for a different environment endpoint.", }), ); } @@ -87,7 +85,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned a descriptor for a different environment.", + detail: "Relay returned a descriptor for a different environment.", }), ); } @@ -104,11 +102,11 @@ function relayAccountId(clerkToken: string): Option.Option { } } -const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { - const relay = yield* ManagedRelayClient; - const session = yield* CloudSession; - const connectivity = yield* Connectivity; - const wakeups = yield* ConnectionWakeups; +export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const connectivity = yield* Connectivity.Connectivity; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); const refreshLock = yield* Semaphore.make(1); const hasRefreshed = yield* Ref.make(false); @@ -327,7 +325,4 @@ const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make" return RelayEnvironmentDiscovery.of({ state, refresh }); }); -export const relayEnvironmentDiscoveryLayer = Layer.effect( - RelayEnvironmentDiscovery, - makeRelayEnvironmentDiscovery(), -); +export const layer = Layer.effect(RelayEnvironmentDiscovery, make()); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index dff78cefae5..507d137cacc 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -22,11 +22,8 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; @@ -46,7 +43,7 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { stage: "downloading", }; -function session(client: WsRpcProtocolClient): RpcSession { +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -58,10 +55,12 @@ function session(client: WsRpcProtocolClient): RpcSession { const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { const state = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); - const activeSession = yield* SubscriptionRef.make>(Option.none()); + const activeSession = yield* SubscriptionRef.make>( + Option.none(), + ); const prepared = yield* SubscriptionRef.make>(Option.none()); const retryCount = yield* Ref.make(0); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state, session: activeSession, @@ -69,7 +68,7 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { connect: Effect.void, disconnect: Effect.void, retryNow: Ref.update(retryCount, (count) => count + 1), - } satisfies EnvironmentSupervisorService); + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); return { activeSession, retryCount, @@ -89,7 +88,7 @@ describe("environment RPC", () => { yield* SubscriptionRef.set(activeSession, Option.some(session(client))); const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService( EnvironmentRpcRequestObserver, EnvironmentRpcRequestObserver.of({ @@ -128,7 +127,7 @@ describe("environment RPC", () => { const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( Stream.take(2), Stream.runCollect, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* Effect.yieldNow; @@ -172,7 +171,7 @@ describe("environment RPC", () => { const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); @@ -212,7 +211,7 @@ describe("environment RPC", () => { const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); @@ -243,7 +242,7 @@ describe("environment RPC", () => { yield* SubscriptionRef.set(activeSession, Option.some(session(client))); const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.flip, ); @@ -283,7 +282,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { @@ -329,7 +328,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); for (let attempt = 0; attempt < 100; attempt += 1) { @@ -377,7 +376,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.exit, ); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 8dec2c2b2b4..76608388f0a 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export * from "./session.ts"; +export { type RpcSession, RpcSessionFactory } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0317806f9b3..7820c93a935 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -18,7 +18,7 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; +import * as RpcSession from "./session.ts"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -149,8 +149,8 @@ const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); - const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); + const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 2c97b75f829..f9594c1b7ca 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -5,7 +5,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; @@ -47,98 +48,97 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ reason: "permission", - message: error.message, + detail: error.message, }); case "KeybindingsConfigParseError": case "ServerSettingsError": return new ConnectionTransientErrorClass({ reason: "remote-unavailable", - message: error.message, + detail: error.message, }); case "RpcClientError": return new ConnectionTransientErrorClass({ reason: "transport", - message: error.message, + detail: error.message, }); } } -export const rpcSessionFactoryLayer = Layer.effect( - RpcSessionFactory, - Effect.gen(function* () { - const webSocketConstructor = yield* Socket.WebSocketConstructor; +export const make = Effect.gen(function* () { + const webSocketConstructor = yield* Socket.WebSocketConstructor; - const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": connection.environmentId, - }); + const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": connection.environmentId, + }); - const connected = yield* Deferred.make(); - const disconnected = yield* Deferred.make(); - const hooks = RpcClient.ConnectionHooks.of({ - onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), - onDisconnect: Deferred.isDone(connected).pipe( - Effect.flatMap((wasConnected) => - Deferred.fail( - disconnected, - new ConnectionTransientErrorClass({ - reason: "transport", - message: wasConnected - ? `${connection.label} disconnected.` - : `${connection.label} could not establish a WebSocket connection.`, - }), - ), - ), - Effect.asVoid, - ), - }); - const socketLayer = Socket.layerWebSocket(connection.socketUrl, { - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); - const protocolLayer = Layer.effect( - RpcClient.Protocol, - RpcClient.makeProtocolSocket({ - retryTransientErrors: false, - retryPolicy: Schedule.recurs(0), - }), - ).pipe( - Layer.provide( - Layer.mergeAll( - socketLayer, - RpcSerialization.layerJson, - Layer.succeed(RpcClient.ConnectionHooks, hooks), + const connected = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const hooks = RpcClient.ConnectionHooks.of({ + onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), + onDisconnect: Deferred.isDone(connected).pipe( + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + detail: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), ), ), - ); - const protocolContext = yield* Layer.build(protocolLayer).pipe( - Effect.withSpan("environment.websocket.connect"), - ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( - Effect.mapError(mapInitialConfigError), - Effect.withSpan("environment.initialSync"), + Effect.asVoid, + ), + }); + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), ), - ); - const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + ), + ); + const protocolContext = yield* Layer.build(protocolLayer).pipe( + Effect.withSpan("environment.websocket.connect"), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfig = yield* Effect.cached( + client[WS_METHODS.serverGetConfig]({}).pipe( Effect.mapError(mapInitialConfigError), + Effect.withSpan("environment.initialSync"), + ), + ); + const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.asVoid, + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ); + + return { + client, + initialConfig, + ready: Deferred.await(connected).pipe( + Effect.andThen(initialConfig), Effect.asVoid, - Effect.withSpan("clientRuntime.connection.rpcSession.probe"), - ); + Effect.raceFirst(Deferred.await(disconnected)), + ), + probe, + closed: Deferred.await(disconnected), + } satisfies RpcSession; + }); - return { - client, - initialConfig, - ready: Deferred.await(connected).pipe( - Effect.andThen(initialConfig), - Effect.asVoid, - Effect.raceFirst(Deferred.await(disconnected)), - ), - probe, - closed: Deferred.await(disconnected), - } satisfies RpcSession; - }); + return RpcSessionFactory.of({ connect }); +}); - return RpcSessionFactory.of({ connect }); - }), -); +export const layer = Layer.effect(RpcSessionFactory, make); diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 6f406b409a2..6dfa5001a48 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -5,10 +5,10 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; -import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { createAtomCommandScheduler, createRuntimeCommand, @@ -26,13 +26,13 @@ export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.f }); export function createEnvironmentCatalogAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const commandScheduler = createAtomCommandScheduler(); const serial = { mode: "serial" as const, key: () => "environment-catalog" }; const catalogAtom = runtime.atom( Stream.unwrap( - EnvironmentRegistry.pipe( + EnvironmentRegistry.EnvironmentRegistry.pipe( Effect.map((registry) => SubscriptionRef.changes(registry.entries).pipe( Stream.map((entries) => ({ @@ -52,7 +52,7 @@ export function createEnvironmentCatalogAtoms( const networkStatusAtom = runtime.atom( Stream.unwrap( - EnvironmentRegistry.pipe( + EnvironmentRegistry.EnvironmentRegistry.pipe( Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), ), ), @@ -68,7 +68,7 @@ export function createEnvironmentCatalogAtoms( followStreamInEnvironment( environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), ), ), @@ -81,29 +81,39 @@ export function createEnvironmentCatalogAtoms( label: "environment-catalog:register", scheduler: commandScheduler, concurrency: serial, - execute: (target: Parameters[0]) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), + execute: ( + target: Parameters[0], + ) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.register(target)), + ), }); const remove = createRuntimeCommand(runtime, { label: "environment-catalog:remove", scheduler: commandScheduler, concurrency: serial, execute: (environmentId: EnvironmentIdType) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.remove(environmentId)), + ), }); const removeRelayEnvironments = createRuntimeCommand(runtime, { label: "environment-catalog:remove-relay-environments", scheduler: commandScheduler, concurrency: serial, execute: (_input: void) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.removeRelayEnvironments()), + ), }); const retryNow = createRuntimeCommand(runtime, { label: "environment-catalog:retry-now", scheduler: commandScheduler, concurrency: serial, execute: (environmentId: EnvironmentIdType) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.retryNow(environmentId)), + ), }); return { diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 5ed4d504ce3..2eab7214225 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -16,12 +16,9 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; @@ -39,7 +36,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { updatedAt: "2026-06-06T00:00:00.000Z", }; -function session(client: WsRpcProtocolClient): RpcSession { +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -57,10 +54,10 @@ describe("environment shell synchronization", () => { [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); - const activeSession = yield* SubscriptionRef.make>( + const activeSession = yield* SubscriptionRef.make>( Option.some(session(client)), ); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, session: activeSession, @@ -68,8 +65,8 @@ describe("environment shell synchronization", () => { connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); - const cache = EnvironmentCacheStore.of({ + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ loadShell: () => Effect.succeed(Option.none()), saveShell: () => Effect.never, loadThread: () => Effect.succeed(Option.none()), @@ -78,8 +75,8 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const shellState = yield* makeEnvironmentShellState().pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), - Effect.provideService(EnvironmentCacheStore, cache), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), ); yield* SubscriptionRef.set(supervisorState, { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index eef2550e2e2..3a5a8b69630 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -24,12 +24,9 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, makeEnvironmentThreadState, @@ -69,7 +66,7 @@ const BASE_THREAD: OrchestrationThread = { type TestThreadInput = OrchestrationThreadStreamItem | Error; -function testSession(client: WsRpcProtocolClient): RpcSession { +function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -117,11 +114,11 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ), } as unknown as WsRpcProtocolClient; - const supervisorSession = yield* SubscriptionRef.make>( + const supervisorSession = yield* SubscriptionRef.make>( Option.some(testSession(client)), ); const prepared = yield* SubscriptionRef.make>(Option.none()); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, session: supervisorSession, @@ -129,8 +126,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o connect: Effect.void, disconnect: Effect.void, retryNow: Ref.update(retryCount, (count) => count + 1), - } satisfies EnvironmentSupervisorService); - const cache = EnvironmentCacheStore.of({ + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ loadShell: () => Effect.succeed(Option.none()), saveShell: () => Effect.void, loadThread: (_environmentId, threadId) => @@ -146,8 +143,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o clear: () => Effect.void, }); const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), - Effect.provideService(EnvironmentCacheStore, cache), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), ); yield* SubscriptionRef.changes(threadState).pipe( Stream.runForEach((state) => From 10a8d3fbd9c44fef2e23a213c61e8b517067e38b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:39:21 -0700 Subject: [PATCH 050/142] Tighten structural Effect error checks (#3213) Co-authored-by: codex --- .macroscope/check-run-agents/effect-service-conventions.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index bcb454a6641..417e03a257a 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -46,6 +46,9 @@ Review changed TypeScript and directly affected call sites for the conventions b ## 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 an error. Flag error classes whose only meaningful field is `cause`, or whose `message` merely stringifies an opaque cause. +- Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, normalized category/status, and a useful detail. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. +- Preserve a real underlying `cause` only when it adds diagnostic value, and make it optional supplemental data alongside the structural fields. Never manufacture an `Error` or opaque defect merely to populate `cause`, and do not erase structured upstream errors into `Schema.Defect()`. - 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. From 6d2dae0627897e84e79bc7d2f164a564357fe9b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:53:48 -0700 Subject: [PATCH 051/142] Preserve full cause chains in Effect error checks (#3215) Co-authored-by: codex --- .macroscope/check-run-agents/effect-service-conventions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 417e03a257a..d21c908d585 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -47,8 +47,9 @@ Review changed TypeScript and directly affected call sites for the conventions b - 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 an error. Flag error classes whose only meaningful field is `cause`, or whose `message` merely stringifies an opaque cause. -- Capture stable, serializable domain context such as the operation or stage, resource/path or entity identifier, normalized category/status, and a useful detail. Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error. -- Preserve a real underlying `cause` only when it adds diagnostic value, and make it optional supplemental data alongside the structural fields. Never manufacture an `Error` or opaque defect merely to populate `cause`, and do not erase structured upstream errors into `Schema.Defect()`. +- 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. - 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. From 65385c028169da7e18d0f25124f0e3f6a1208567 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:59:43 -0700 Subject: [PATCH 052/142] [codex] Refactor desktop settings Effect services (#3188) Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 117 ++++- .../src/app/DesktopConnectionCatalogStore.ts | 368 ++++++++++----- .../src/settings/DesktopAppSettings.test.ts | 59 ++- .../src/settings/DesktopAppSettings.ts | 191 ++++---- .../settings/DesktopClientSettings.test.ts | 22 +- .../src/settings/DesktopClientSettings.ts | 120 +++-- .../settings/DesktopSavedEnvironments.test.ts | 154 ++++++- .../src/settings/DesktopSavedEnvironments.ts | 436 +++++++++++------- 8 files changed, 994 insertions(+), 473 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c2bd8776e67..26c0c8f8943 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -21,7 +21,6 @@ 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), @@ -40,7 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref return decoded.slice("encrypted:".length); }); }, - } satisfies ElectronSafeStorage.ElectronSafeStorageShape); + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } function makeLayer( @@ -236,8 +235,11 @@ describe("DesktopConnectionCatalogStore", () => { const error = yield* store.get.pipe(Effect.flip); assert.instanceOf( error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, + DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, ); + assert.equal(error.operation, "decode-catalog-document"); + assert.equal(error.catalogPath, catalogPath); + assert.exists(error.cause); assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); }), ), @@ -253,7 +255,7 @@ describe("DesktopConnectionCatalogStore", () => { _tag: "PermissionDenied", module: "FileSystem", method: "readFileString", - pathOrDescriptor: `${baseDir}/connection-catalog.json`, + pathOrDescriptor: `${baseDir}/userdata/connection-catalog.json`, }); const fileSystemLayer = Layer.succeed( FileSystem.FileSystem, @@ -270,10 +272,115 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.cause, permissionError); + assert.equal(error.operation, "read-catalog"); + 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.operation, "decode-encrypted-catalog"); + 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; diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 0b382bb163c..7eaf3ec7cf6 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -14,14 +14,12 @@ 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 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"; @@ -48,76 +46,156 @@ const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); -export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( +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", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to write desktop connection catalog: ${this.cause.message}`; + { + operation: DesktopConnectionCatalogStoreWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop connection catalog write failed during ${this.operation} at ${this.path}.`; } } -export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( +const writeError = ( + operation: DesktopConnectionCatalogStoreWriteOperation, + path: string, + cause: unknown, +): DesktopConnectionCatalogStoreWriteError => + new DesktopConnectionCatalogStoreWriteError({ + operation, + path, + cause, + }); + +export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", -)<{ - readonly cause: Encoding.EncodingError; -}> { - override get message() { - return "Failed to decode the desktop connection catalog."; + { + operation: Schema.Literal("decode-encrypted-catalog"), + 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 Data.TaggedError( +export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to read desktop connection catalog: ${this.cause.message}`; + { + operation: Schema.Literal("read-catalog"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the desktop connection catalog at ${this.catalogPath}.`; } } -export class DesktopConnectionCatalogStoreMigrationError extends Data.TaggedError( - "DesktopConnectionCatalogStoreMigrationError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Failed to migrate legacy desktop saved environments."; +export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( + "DesktopConnectionCatalogStoreDocumentDecodeError", + { + operation: Schema.Literal("decode-catalog-document"), + catalogPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode the desktop connection catalog document at ${this.catalogPath}.`; } } -export interface DesktopConnectionCatalogStoreShape { - readonly get: Effect.Effect< - Option.Option, - | DesktopConnectionCatalogStoreReadError - | DesktopConnectionCatalogStoreDecodeError - | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError - >; - readonly set: ( - catalog: string, - ) => Effect.Effect< - boolean, - | DesktopConnectionCatalogStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError - >; - readonly clear: Effect.Effect; +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, - DesktopConnectionCatalogStoreShape + { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreReadError + | DesktopConnectionCatalogStoreDocumentDecodeError + | DesktopConnectionCatalogStoreDecodeError + | DesktopConnectionCatalogStoreMigrationError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + | DesktopConnectionCatalogStoreWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError + >; + 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({ cause })), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDecodeError({ + operation: "decode-encrypted-catalog", + resource: "encryptedCatalog", + catalogPath, + cause, + }), + ), ); } @@ -126,16 +204,34 @@ const readDocument = ( catalogPath: string, ): Effect.Effect< Option.Option, - PlatformError.PlatformError | Schema.SchemaError + DesktopConnectionCatalogStoreReadError | DesktopConnectionCatalogStoreDocumentDecodeError > => fileSystem.readFileString(catalogPath).pipe( Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopConnectionCatalogStoreReadError({ + operation: "read-catalog", + catalogPath, + cause: error, + }), + ), ), Effect.flatMap((raw) => raw === null ? Effect.succeed(Option.none()) - : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe(Effect.map(Option.some)), + : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreDocumentDecodeError({ + operation: "decode-catalog-document", + catalogPath, + cause, + }), + ), + ), ), ); @@ -145,14 +241,24 @@ const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")( readonly catalogPath: string; readonly document: EncryptedConnectionCatalogDocument; readonly suffix: string; -}): Effect.fn.Return { +}): 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); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + 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`); - yield* input.fileSystem.rename(tempPath, input.catalogPath); + 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( @@ -175,10 +281,11 @@ const migrateSavedEnvironmentRecords = Effect.fn( "desktop.connectionCatalogStore.migrateSavedEnvironmentRecords", )(function* ( records: readonly PersistedSavedEnvironmentRecord[], - savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironmentsShape, + savedEnvironments: DesktopSavedEnvironments.DesktopSavedEnvironments["Service"], + catalogPath: string, ): Effect.fn.Return< RuntimeConnectionCatalogDocumentType, - DesktopSavedEnvironments.DesktopSavedEnvironmentsGetSecretError + DesktopConnectionCatalogStoreMigrationError > { const targets: Array = []; const profiles: Array = []; @@ -232,7 +339,13 @@ const migrateSavedEnvironmentRecords = Effect.fn( wsBaseUrl: record.wsBaseUrl, }), ); - const token = yield* savedEnvironments.getSecret(record.environmentId); + 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, @@ -250,79 +363,82 @@ const migrateSavedEnvironmentRecords = Effect.fn( }; }); -export const layer = Layer.effect( - DesktopConnectionCatalogStore, - 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"); +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) => new DesktopConnectionCatalogStoreWriteError({ cause })), - )).replace(/-/g, ""); - yield* writeDocument({ - fileSystem, - path, - catalogPath, - document: { version: 1, encryptedCatalog }, - suffix, - }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause }))); + 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* () { + 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 records = yield* savedEnvironments.getRegistry; - if (records.length === 0) { - 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; } - const catalog = yield* migrateSavedEnvironmentRecords(records, savedEnvironments); - const encoded = yield* encodeRuntimeConnectionCatalogDocumentJson(catalog); - yield* writeCatalog(encoded); - return Option.some(encoded); - }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreMigrationError({ cause }))); - - return DesktopConnectionCatalogStore.of({ - get: Effect.gen(function* () { - const document = yield* readDocument(fileSystem, catalogPath).pipe( - Effect.mapError((cause) => new DesktopConnectionCatalogStoreReadError({ cause })), - ); - if (Option.isNone(document)) { - return yield* migrateLegacyCatalog; - } - if (!(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - const decrypted = yield* decodeSecretBytes(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"), + 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/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..2d1d7fc547d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,7 +34,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 +105,27 @@ 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.exists(error.cause); + assert.equal( + error.message, + `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, + ); + }), + ), + ); + 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..585397d7502 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"; @@ -31,24 +29,43 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect()( "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; -} +const writeError = ( + operation: DesktopClientSettingsWriteOperation, + path: string, + cause: unknown, +): DesktopClientSettingsWriteError => + new DesktopClientSettingsWriteError({ operation, path, cause }); 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 +92,56 @@ 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) => 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( - 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, - }), - ), - Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), - Effect.withSpan("desktop.clientSettings.set"), + 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) => + writeError("create-temporary-file-name", environment.clientSettingsPath, cause), ), - }); - }), -); + 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 abf8394cdb4..4e3c8d8ba1d 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,37 @@ 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.operation, "decode-secret"); + 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* () { @@ -321,16 +361,98 @@ describe("DesktopSavedEnvironments", () => { const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); assert.instanceOf( registryError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError, + DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); + assert.equal(registryError.operation, "decode-registry"); + assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); + assert.exists(registryError.cause); const secretError = yield* savedEnvironments .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); - assert.instanceOf(secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); + 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.operation, "read-registry"); + 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 195992f0472..490777e9e84 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,73 +70,123 @@ 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}.`; } } -export class DesktopSavedEnvironmentsReadError extends Data.TaggedError( +const writeError = ( + operation: DesktopSavedEnvironmentsWriteOperation, + path: string, + cause: unknown, +): DesktopSavedEnvironmentsWriteError => + new DesktopSavedEnvironmentsWriteError({ + operation, + path, + cause, + }); + +export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", -)<{ - readonly cause: PlatformError.PlatformError | Schema.SchemaError; -}> { - override get message() { - return `Failed to read desktop saved environments: ${this.cause.message}`; + { + operation: Schema.Literal("read-registry"), + 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", + { + operation: Schema.Literal("decode-registry"), + 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."; + { + operation: Schema.Literal("decode-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 DesktopSavedEnvironmentsGetSecretError = +export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError + | DesktopSavedEnvironmentsDocumentDecodeError; + +export type DesktopSavedEnvironmentsMutationError = + | DesktopSavedEnvironmentsReadRegistryError + | DesktopSavedEnvironmentsWriteError; + +export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageDecryptError; export type DesktopSavedEnvironmentsSetSecretError = - | DesktopSavedEnvironmentsWriteError + | DesktopSavedEnvironmentsMutationError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageEncryptError; -export interface DesktopSavedEnvironmentsShape { - readonly getRegistry: Effect.Effect< - readonly PersistedSavedEnvironmentRecord[], - DesktopSavedEnvironmentsReadError - >; - 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; -} - 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( @@ -193,19 +241,32 @@ function normalizeSavedEnvironmentRegistryDocument( function readRegistryDocument( fileSystem: FileSystem.FileSystem, registryPath: string, -): Effect.Effect< - SavedEnvironmentRegistryDocument, - PlatformError.PlatformError | Schema.SchemaError -> { +): Effect.Effect { return fileSystem.readFileString(registryPath).pipe( Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new DesktopSavedEnvironmentsReadError({ + operation: "read-registry", + registryPath, + cause: error, + }), + ), ), Effect.flatMap((raw) => raw === null ? Effect.succeed({ version: 1, records: [] }) : decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( Effect.map(normalizeSavedEnvironmentRegistryDocument), + Effect.mapError( + (cause) => + new DesktopSavedEnvironmentsDocumentDecodeError({ + operation: "decode-registry", + registryPath, + cause, + }), + ), ), ), ); @@ -218,13 +279,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)), + ); }, ); @@ -250,147 +321,160 @@ 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({ + operation: "decode-secret", + 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.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause })), - 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( + Effect.flatMap((suffix) => + writeRegistryDocument({ fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - yield* writeDocument(preserveExistingSecrets(currentDocument, records)); - }), - removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( - function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - if (!document.records.some((record) => record.environmentId === environmentId)) { - return; - } - - yield* writeDocument({ - version: document.version, - records: document.records.filter((record) => record.environmentId !== environmentId), - }); - }, + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + suffix, + }), ), - getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsReadError({ cause }))); - 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; + 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, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - - 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, - ).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); - 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[]; From 2fbbe68c24892c4de9bd50450e7363376475ad7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:27:15 -0700 Subject: [PATCH 053/142] Clarify Effect error discriminator modeling (#3217) Co-authored-by: codex --- .macroscope/check-run-agents/effect-service-conventions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index d21c908d585..b49cda95ebe 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -50,6 +50,7 @@ Review changed TypeScript and directly affected call sites for the conventions b - 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. - 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. From 335e0b59ea8a1bbc381402d1fbea268e05095f74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:38:54 -0700 Subject: [PATCH 054/142] Fix PR creation from origin-based worktrees (#3218) Co-authored-by: Julius Marminge --- apps/server/src/git/GitManager.test.ts | 56 ++++++++++++++++++++ apps/server/src/git/GitManager.ts | 24 ++++++++- apps/server/src/server.test.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 8 +++ apps/server/src/vcs/GitVcsDriverCore.ts | 22 ++++++-- apps/server/src/ws.ts | 1 + packages/contracts/src/git.test.ts | 12 +++++ packages/contracts/src/git.ts | 1 + 8 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 165c351b36c..2b296e5f3fa 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2239,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"]); + fs.writeFileSync(path.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", + ]); + fs.writeFileSync(path.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", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 9938c40cffb..c57c814f437 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1092,6 +1092,27 @@ export const make = Effect.gen(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; @@ -1298,7 +1319,8 @@ export const make = Effect.gen(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, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1529285e50c..76824af73e3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6041,6 +6041,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/project", refName: fetchedOriginCommit, newRefName: "t3code/bootstrap-refName", + baseRefName: "main", path: null, }); assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5be6427fe73..2fd4d447c58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -568,13 +568,21 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { 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); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0e8f8df16e2..23a968a9cfe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -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; @@ -2178,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, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7eb4ba882d9..0b25b25f6f6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -712,6 +712,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => cwd: bootstrap.prepareWorktree.projectCwd, refName: worktreeBaseRef, newRefName: bootstrap.prepareWorktree.branch, + baseRefName: bootstrap.prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 0a5497367cd..4ea86670ff8 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -28,6 +28,18 @@ describe("VcsCreateWorktreeInput", () => { expect(parsed.newRefName).toBeUndefined(); expect(parsed.refName).toBe("feature/existing"); }); + + it("accepts baseRefName metadata for a new worktree ref", () => { + const parsed = decodeCreateWorktreeInput({ + cwd: "/repo", + refName: "0123456789abcdef", + newRefName: "feature/new", + baseRefName: "origin/main", + path: "/tmp/worktree", + }); + + expect(parsed.baseRefName).toBe("origin/main"); + }); }); describe("GitPreparePullRequestThreadInput", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 3de6c84fa44..7ee2a571963 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -137,6 +137,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, newRefName: Schema.optional(TrimmedNonEmptyStringSchema), + baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; From dc82f792c3fc43a06edca916a1da297af714600a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:48:41 -0700 Subject: [PATCH 055/142] [codex] Close right panel when its last tab closes (#3221) Co-authored-by: Julius Marminge --- apps/web/src/rightPanelStore.test.ts | 12 ++++++------ apps/web/src/rightPanelStore.ts | 14 +++++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index fb6d56f98c7..b48f6e7ebb5 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -338,12 +338,12 @@ describe("rightPanelStore", () => { }); }); - it("closing the final terminal pane removes its surface but keeps the panel open", () => { + it("closing the final terminal pane removes its surface and closes the panel", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); @@ -359,12 +359,12 @@ describe("rightPanelStore", () => { ); }); - it("closing the final surface leaves the panel open and empty", () => { + it("closing the final surface closes the panel", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); @@ -406,14 +406,14 @@ describe("rightPanelStore", () => { }); }); - it("closing all surfaces leaves the panel open and empty", () => { + it("closing all surfaces closes the panel", () => { useRightPanelStore.getState().openBrowser(refA, "tab-a"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); useRightPanelStore.getState().closeAllSurfaces(refA); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, + isOpen: false, activeSurfaceId: null, surfaces: [], }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 26dfe8c5153..70d163306cc 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -340,6 +340,7 @@ export const useRightPanelStore = create()( const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, + isOpen: surfaces.length > 0 && current.isOpen, surfaces, activeSurfaceId: current.activeSurfaceId === surfaceId @@ -378,9 +379,16 @@ export const useRightPanelStore = create()( const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); - if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; + if (current.activeSurfaceId !== surfaceId) { + return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces }; + } const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; - return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; + return { + ...current, + isOpen: surfaces.length > 0 && current.isOpen, + surfaces, + activeSurfaceId: fallback?.id ?? null, + }; }), })), closeOtherSurfaces: (ref, surfaceId) => @@ -417,7 +425,7 @@ export const useRightPanelStore = create()( byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current - : { ...current, isOpen: true, surfaces: [], activeSurfaceId: null }, + : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), })), reconcileBrowserSurfaces: (ref, tabIds) => From 8d61172f8bbb675e7472bb949f33bb40a987d454 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:49:33 +0200 Subject: [PATCH 056/142] Cosmetic fix: Sync web title with nightly server branding (#3219) Co-authored-by: Codex --- apps/web/src/branding.logic.ts | 34 +++++++++++++++++ apps/web/src/branding.test.ts | 48 ++++++++++++++++++++++++ apps/web/src/branding.ts | 4 +- apps/web/src/components/Sidebar.logic.ts | 7 +--- apps/web/src/main.tsx | 3 -- apps/web/src/routes/__root.tsx | 35 +++++++++++++++-- 6 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/branding.logic.ts 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/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5c70d447d2b..4e7614ed551 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,10 +9,10 @@ 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; -const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; @@ -69,10 +69,7 @@ export function resolveSidebarStageBadgeLabel(input: { primaryServerVersion: string | null | undefined; fallbackStageLabel: string; }): string { - return input.primaryServerVersion && - NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) - ? "Nightly" - : input.fallbackStageLabel; + return resolveServerBackedAppStageLabel(input); } export function createThreadJumpHintVisibilityController(input: { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 838a990d6c6..453649bfdc5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -15,7 +15,6 @@ import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; -import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; @@ -28,8 +27,6 @@ if (isElectron) { syncDocumentWindowControlsOverlayClass(); } -document.title = APP_DISPLAY_NAME; - const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; const app = ; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d01518a3858..035f59ad93a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,7 +10,8 @@ import { } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; +import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; +import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; @@ -96,11 +97,21 @@ function RootRouteView() { }, [pathname]); if (pathname === "/pair") { - return ; + return ( + <> + + + + ); } if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { - return ; + return ( + <> + + + + ); } const appShell = ( @@ -114,6 +125,7 @@ function RootRouteView() { return ( + {primaryEnvironmentAuthenticated ? : null} @@ -127,6 +139,23 @@ function RootRouteView() { ); } +function DocumentTitleSync() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const title = resolveServerBackedAppDisplayName({ + baseName: APP_BASE_NAME, + fallbackDisplayName: APP_DISPLAY_NAME, + fallbackStageLabel: APP_STAGE_LABEL, + primaryServerVersion, + }); + + useEffect(() => { + document.title = title; + }, [title]); + + return null; +} + function HostedStaticEnvironmentBootstrap() { const { environments } = useEnvironments(); const activeEnvironmentId = useActiveEnvironmentId(); From 3900c45f25049ea8df359736f0ea8ad97bd3624f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:49:51 -0700 Subject: [PATCH 057/142] Preserve observable Effect error semantics (#3220) Co-authored-by: codex --- .macroscope/check-run-agents/effect-service-conventions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b49cda95ebe..1bbd8192cb5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -46,11 +46,12 @@ Review changed TypeScript and directly affected call sites for the conventions b ## 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 an error. Flag error classes whose only meaningful field is `cause`, or whose `message` merely stringifies an opaque cause. +- `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. From 79c57170c7fb9cc1844427aed93c5f297fb110b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:08:32 -0700 Subject: [PATCH 058/142] [codex] Make settings environment-scoped by default (#3216) Co-authored-by: Julius Marminge --- apps/mobile/src/state/entities.ts | 5 +- apps/mobile/src/state/presentation.ts | 4 +- apps/mobile/src/state/server.ts | 4 +- apps/web/src/components/ChatView.tsx | 12 ++- apps/web/src/components/CommandPalette.tsx | 52 +++------- apps/web/src/components/DiffPanel.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 41 ++++---- .../components/chat/ModelPickerContent.tsx | 6 +- .../settings/AddProviderInstanceDialog.tsx | 6 +- .../components/settings/SettingsPanels.tsx | 14 +-- .../settings/SourceControlSettings.tsx | 8 +- apps/web/src/hooks/useHandleNewThread.ts | 24 +++-- apps/web/src/hooks/useSettings.test.ts | 37 ++++++++ apps/web/src/hooks/useSettings.ts | 95 ++++++++++++++----- apps/web/src/hooks/useThreadActions.ts | 6 +- apps/web/src/lib/chatThreadActions.test.ts | 8 +- apps/web/src/lib/chatThreadActions.ts | 9 +- apps/web/src/routes/__root.tsx | 4 +- apps/web/src/routes/_chat.tsx | 11 --- apps/web/src/state/entities.ts | 6 ++ apps/web/src/state/environments.ts | 11 +-- apps/web/src/state/presentation.ts | 4 +- apps/web/src/state/primaryEnvironment.ts | 12 +++ apps/web/src/state/server.ts | 6 +- .../client-runtime/src/state/presentation.ts | 5 +- .../src/state/projectGrouping.ts | 4 +- packages/client-runtime/src/state/server.ts | 12 +++ packages/client-runtime/src/state/session.ts | 15 ++- .../client-runtime/src/state/shell.test.ts | 2 +- packages/client-runtime/src/state/shell.ts | 4 +- 30 files changed, 254 insertions(+), 177 deletions(-) create mode 100644 apps/web/src/hooks/useSettings.test.ts create mode 100644 apps/web/src/state/primaryEnvironment.ts diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 9eec5dc1250..8199dee3486 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -12,8 +12,7 @@ import type { import { Atom } from "effect/unstable/reactivity"; import { environmentProjects } from "./projects"; -import { environmentServerConfigsAtom } from "./server"; -import { environmentSession } from "./session"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { environmentThreadShells } from "./threads"; const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( @@ -50,7 +49,7 @@ export function useEnvironmentServerConfig( return useAtomValue( environmentId === null ? EMPTY_SERVER_CONFIG_ATOM - : environmentSession.configValueAtom(environmentId), + : serverEnvironment.configValueAtom(environmentId), ); } diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts index 83d1fdce462..96171d3ea5c 100644 --- a/apps/mobile/src/state/presentation.ts +++ b/apps/mobile/src/state/presentation.ts @@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; -import { environmentSession } from "./session"; +import { serverEnvironment } from "./server"; export const environmentPresentations = createEnvironmentPresentationAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, stateAtom: environmentCatalog.stateAtom, - configValueAtom: environmentSession.configValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index f72cc96e54a..1b7060571a5 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -6,9 +6,9 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { - initialConfigValueAtom: environmentSession.configValueAtom, + initialConfigValueAtom: environmentSession.initialConfigValueAtom, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, - configValueAtom: serverEnvironment.configValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63674076151..cf5bb9de5e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,7 +138,7 @@ import { } from "~/projectScripts"; 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"; @@ -1027,7 +1027,7 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); - const settings = useSettings(); + const settings = useEnvironmentSettings(environmentId); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1417,7 +1417,7 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1586,7 +1586,11 @@ function ChatViewContent(props: ChatViewProps) { selectedProvider: selectedProviderByThreadId, threadProvider, }); - const serverConfig = activeEnvironment?.serverConfig ?? primaryEnvironment?.serverConfig ?? null; + // 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 diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ad84fa72c2d..a11d6c4cb07 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -45,7 +45,7 @@ import { 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 { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; @@ -447,7 +447,7 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); - const settings = useSettings(); + const clientSettings = useClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -524,16 +524,14 @@ function OpenCommandPaletteDialog(props: { const environment = environments.find( (candidate) => candidate.environmentId === environmentId, ); - const environmentSettings = - environment?.serverConfig?.settings ?? - (environmentId === primaryEnvironmentId ? settings : null); + const environmentSettings = environment?.serverConfig?.settings ?? null; const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [environments, primaryEnvironmentId, settings], + [environments], ); const projectCwdById = useMemo( @@ -589,7 +587,7 @@ function OpenCommandPaletteDialog(props: { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === project.environmentId), project.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -601,17 +599,9 @@ function OpenCommandPaletteDialog(props: { 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( @@ -650,21 +640,13 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, 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( @@ -673,7 +655,7 @@ function OpenCommandPaletteDialog(props: { threads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, - sortOrder: settings.sidebarThreadSortOrder, + sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , @@ -684,7 +666,7 @@ function OpenCommandPaletteDialog(props: { }); }, }), - [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -956,7 +938,6 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, }); }, @@ -1072,7 +1053,7 @@ function OpenCommandPaletteDialog(props: { const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, ); if (latestThread) { await navigate({ @@ -1083,9 +1064,7 @@ function OpenCommandPaletteDialog(props: { }); } else { const navigationResult = await settlePromise(() => - handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { - envMode: settings.defaultThreadEnvMode, - }), + handleNewThread(scopeProjectRef(existing.environmentId, existing.id)), ); if (navigationResult._tag === "Failure") { const error = squashAtomCommandFailure(navigationResult); @@ -1132,9 +1111,7 @@ function OpenCommandPaletteDialog(props: { } const navigationResult = await settlePromise(() => - handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { - envMode: settings.defaultThreadEnvMode, - }), + handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), ); if (navigationResult._tag === "Failure") { const error = squashAtomCommandFailure(navigationResult); @@ -1158,8 +1135,7 @@ function OpenCommandPaletteDialog(props: { navigate, projects, setOpen, - settings.defaultThreadEnvMode, - settings.sidebarThreadSortOrder, + clientSettings.sidebarThreadSortOrder, threads, ], ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a7309b44f4c..ae356fe08ef 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -34,7 +34,7 @@ import { import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; -import { useSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; @@ -183,7 +183,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { const { resolvedTheme } = useTheme(); - const settings = useSettings(); + const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0d6b4b6d1c9..f6eb8602cf8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, + DEFAULT_SERVER_SETTINGS, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, - type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; import { @@ -77,6 +77,7 @@ import { readThreadShell, useProject, useProjects, + useServerConfigs, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; @@ -199,7 +200,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; -import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -1084,19 +1085,17 @@ 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 projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const serverConfigs = useServerConfigs(); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1106,8 +1105,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); - const updateSettings = useUpdateSettings(); - const sidebarThreadPreviewCount = useSettings( + const updateSettings = useUpdateClientSettings(); + const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, ); const router = useRouter(); @@ -1840,7 +1839,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 @@ -1889,7 +1890,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } })(); }, - [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], + [handleNewThread, isMobile, router, serverConfigs, setOpenMobile], ); const handleCreateThreadClick = useCallback( @@ -2745,7 +2746,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; - updateSettings: ReturnType; + updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -3014,12 +3015,12 @@ export default function Sidebar() { 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 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(); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index f357218c22a..21570fb7125 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -19,7 +19,7 @@ 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 { @@ -102,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) { @@ -117,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 }); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 59f069c9e2b..77c1813f110 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -9,7 +9,7 @@ import { 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,8 +114,8 @@ 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); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 20ebafbce40..6e08fa68ef1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -36,7 +36,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -373,8 +373,8 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); - const settings = useSettings(); - const updateSettings = useUpdateSettings(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -479,8 +479,8 @@ export function useSettingsRestore(onRestored?: () => void) { export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); - const settings = useSettings(); - const updateSettings = useUpdateSettings(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ @@ -977,8 +977,8 @@ export function GeneralSettingsPanel() { } export function ProviderSettingsPanel() { - const settings = useSettings(); - const updateSettings = useUpdateSettings(); + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); const serverProviders = useAtomValue(primaryServerProvidersAtom); const primaryEnvironment = usePrimaryEnvironment(); const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index db1b2393626..b6d23de4f79 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -12,7 +12,7 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; @@ -291,8 +291,10 @@ function DiscoveryItemRow({ } function GitFetchIntervalSettings() { - const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); - const updateSettings = useUpdateSettings(); + const automaticGitFetchInterval = usePrimarySettings( + (settings) => settings.automaticGitFetchInterval, + ); + const updateSettings = useUpdatePrimarySettings(); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c99ae0af9b8..1b1c07b31c9 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -3,7 +3,11 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -19,18 +23,16 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { useSettings } from "./useSettings"; +import { useClientSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); - const newWorktreesStartFromOrigin = useSettings( - (settings) => settings.newWorktreesStartFromOrigin, - ); + const serverConfigs = useServerConfigs(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -61,6 +63,8 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + const environmentSettings = + serverConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); @@ -155,7 +159,7 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); - const initialEnvMode = options?.envMode ?? "local"; + const initialEnvMode = options?.envMode ?? environmentSettings.defaultThreadEnvMode; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, @@ -167,7 +171,7 @@ export function useNewThreadHandler() { options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: environmentSettings.newWorktreesStartFromOrigin, }), runtimeMode: DEFAULT_RUNTIME_MODE, }); @@ -179,7 +183,7 @@ export function useNewThreadHandler() { }); })(); }, - [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, projects, router, serverConfigs], ); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts new file mode 100644 index 00000000000..7132c84c9d3 --- /dev/null +++ b/apps/web/src/hooks/useSettings.test.ts @@ -0,0 +1,37 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeEnvironmentSettings } from "./useSettings"; + +describe("mergeEnvironmentSettings", () => { + it("combines the selected environment's server settings with client preferences", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex_remote")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + }; + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + favorites: [ + { + provider: ProviderInstanceId.make("codex_remote"), + model: "gpt-5.4", + }, + ], + }; + + const settings = mergeEnvironmentSettings(serverSettings, clientSettings); + + expect(settings.providerInstances).toBe(serverSettings.providerInstances); + expect(settings.favorites).toBe(clientSettings.favorites); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6759b227a13..bf8b3a7dd08 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -1,22 +1,27 @@ /** - * Unified settings hook. + * Environment-scoped settings hooks. * * Abstracts the split between server-authoritative settings (persisted in * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * - * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to - * write. The hook transparently routes reads/writes to the correct backing - * store. + * Live server settings always require an environment id. Primary-environment + * access is intentionally named as such so environment-sensitive consumers + * cannot silently read the wrong server's settings. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, - UnifiedSettings, + type UnifiedSettings, } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; @@ -153,11 +158,6 @@ function splitPatch(patch: Partial): { // ── Hooks ──────────────────────────────────────────────────────────── -/** - * Read merged settings. Selector narrows the subscription so components - * only re-render when the slice they care about changes. - */ - /** * Non-hook accessor for the current merged client settings snapshot. * Used by non-React code paths (e.g. runtime services) that need the latest @@ -175,45 +175,77 @@ export function useClientSettingsHydrated(): boolean { ); } -export function useSettings(selector?: (s: UnifiedSettings) => T): T { - const serverSettings = useAtomValue(primaryServerSettingsAtom); - const clientSettings = useSyncExternalStore( +function useClientSettingsValue(): ClientSettings { + return useSyncExternalStore( subscribeClientSettings, getClientSettingsSnapshot, () => DEFAULT_CLIENT_SETTINGS, ); +} + +export function mergeEnvironmentSettings( + serverSettings: ServerSettings, + clientSettings: ClientSettings, +): UnifiedSettings { + return { ...serverSettings, ...clientSettings }; +} + +function useMergedSettings( + serverSettings: ServerSettings, + selector: ((settings: UnifiedSettings) => T) | undefined, +): T { + const clientSettings = useClientSettingsValue(); const merged = useMemo( - () => ({ - ...serverSettings, - ...clientSettings, - }), + () => mergeEnvironmentSettings(serverSettings, clientSettings), [clientSettings, serverSettings], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); } +export function useClientSettings( + selector?: (settings: ClientSettings) => T, +): T { + const settings = useClientSettingsValue(); + return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); +} + +/** Read current settings for one environment, merged with client-local preferences. */ +export function useEnvironmentSettings( + environmentId: EnvironmentId, + selector?: (settings: UnifiedSettings) => T, +): T { + const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); +} + +/** Primary-only settings access for the settings UI and other explicitly global surfaces. */ +export function usePrimarySettings( + selector?: (settings: UnifiedSettings) => T, +): T { + return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); +} + /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Client keys go through client persistence. */ -export function useUpdateSettings() { +function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); - const primaryEnvironment = usePrimaryEnvironment(); const updateSettings = useCallback( (patch: Partial) => { const { serverPatch, clientPatch } = splitPatch(patch); if (Object.keys(serverPatch).length > 0) { - if (primaryEnvironment) { + if (environmentId) { void persistServerSettings({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: { patch: serverPatch }, }); } @@ -226,12 +258,29 @@ export function useUpdateSettings() { }); } }, - [persistServerSettings, primaryEnvironment], + [environmentId, persistServerSettings], ); return updateSettings; } +export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { + return useUpdateSettingsTarget(environmentId); +} + +export function useUpdatePrimarySettings() { + return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); +} + +export function useUpdateClientSettings() { + return useCallback((patch: ClientSettingsPatch) => { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...patch, + }); + }, []); +} + export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f174ed8e6c6..35783348068 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -24,7 +24,7 @@ import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useSettings } from "./useSettings"; +import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{ @@ -49,8 +49,8 @@ export function useThreadActions() { const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); - const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); - const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); + const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 62e5aa41d43..2b1d7b09b9f 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -18,7 +18,6 @@ function createContext(overrides: Partial = {}): ChatTh activeDraftThread: null, activeThread: undefined, defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), - defaultThreadEnvMode: "local", handleNewThread: async () => {}, ...overrides, }; @@ -118,21 +117,18 @@ describe("chatThreadActions", () => { }); }); - it("starts a local thread with the configured default env mode", async () => { + it("delegates the target environment defaults to the new-thread handler", async () => { const handleNewThread = vi.fn(async () => {}); const didStart = await startNewLocalThreadFromContext( createContext({ defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), - defaultThreadEnvMode: "worktree", handleNewThread, }), ); expect(didStart).toBe(true); - expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { - envMode: "worktree", - }); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); it("does not start a thread when there is no project context", async () => { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 63d0289d104..4f30885610a 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -32,7 +32,6 @@ export interface ChatThreadActionContext { readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; - readonly defaultThreadEnvMode: DraftThreadEnvMode; readonly handleNewThread: NewThreadHandler; } @@ -72,12 +71,6 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre }; } -function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions { - return { - envMode: context.defaultThreadEnvMode, - }; -} - export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, @@ -105,6 +98,6 @@ export async function startNewLocalThreadFromContext( return false; } - await context.handleNewThread(projectRef, buildDefaultThreadOptions(context)); + await context.handleNewThread(projectRef); return true; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 035f59ad93a..36de3b95706 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,7 +26,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { useSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -266,7 +266,7 @@ function AuthenticatedTracingBootstrap() { function EventRouter() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); - const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironment = usePrimaryEnvironment(); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index cc24ed6090d..9fb1eae721e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -16,9 +16,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { useSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { @@ -40,8 +38,6 @@ function ChatRouteGlobalShortcuts() { ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" : false, ); - const appSettings = useSettings(); - useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; @@ -71,9 +67,6 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), handleNewThread, }); return; @@ -86,9 +79,6 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, - defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), handleNewThread, }); return; @@ -153,7 +143,6 @@ function ChatRouteGlobalShortcuts() { routeThreadRef, selectedThreadKeysSize, terminalOpen, - appSettings.defaultThreadEnvMode, ]); return null; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 2d827e36b3a..b4fc8cc5e80 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -12,12 +12,14 @@ import type { OrchestrationThreadActivity, ScopedProjectRef, ScopedThreadRef, + ServerConfig, } from "@t3tools/contracts"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom } from "./server"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); @@ -103,6 +105,10 @@ export function useProjects(): ReadonlyArray { return useAtomValue(environmentProjects.projectsAtom); } +export function useServerConfigs(): ReadonlyMap { + return useAtomValue(environmentServerConfigsAtom); +} + export function useThreadShells(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadShellsAtom); } diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 38d19f90b54..211c981c5f6 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -21,15 +21,6 @@ export interface EnvironmentPresentation extends BaseEnvironmentPresentation { readonly relayManaged: boolean; } -export const primaryEnvironmentIdAtom = Atom.make((get) => { - for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { - if (entry.target._tag === "PrimaryConnectionTarget") { - return environmentId; - } - } - return null; -}).pipe(Atom.withLabel("web-primary-environment-id")); - function projectEnvironmentPresentation( environmentId: EnvironmentId, presentation: BaseEnvironmentPresentation, diff --git a/apps/web/src/state/presentation.ts b/apps/web/src/state/presentation.ts index 0a4cfd12556..1c2fb7b6a62 100644 --- a/apps/web/src/state/presentation.ts +++ b/apps/web/src/state/presentation.ts @@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; -import { environmentSession } from "./session"; +import { serverEnvironment } from "./server"; export const environmentPresentations = createEnvironmentPresentationAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, stateAtom: environmentCatalog.stateAtom, - configValueAtom: environmentSession.configValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( diff --git a/apps/web/src/state/primaryEnvironment.ts b/apps/web/src/state/primaryEnvironment.ts new file mode 100644 index 00000000000..e37931f74a8 --- /dev/null +++ b/apps/web/src/state/primaryEnvironment.ts @@ -0,0 +1,12 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; + +export const primaryEnvironmentIdAtom = Atom.make((get) => { + for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { + if (entry.target._tag === "PrimaryConnectionTarget") { + return environmentId; + } + } + return null; +}).pipe(Atom.withLabel("web-primary-environment-id")); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 94561f2f207..3271eefd1e1 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -15,15 +15,15 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { primaryEnvironmentIdAtom } from "./environments"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { - initialConfigValueAtom: environmentSession.configValueAtom, + initialConfigValueAtom: environmentSession.initialConfigValueAtom, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, - configValueAtom: serverEnvironment.configValueAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); interface PrimaryServerState { diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 1321ece93f8..d6fed0cf5ed 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -26,7 +26,8 @@ export function createEnvironmentPresentationAtoms(input: { readonly stateAtom: ( environmentId: EnvironmentId, ) => Atom.Atom>; - readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + /** Authoritative live server config, including streamed provider/settings updates. */ + readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; }) { const presentationAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => { @@ -41,7 +42,7 @@ export function createEnvironmentPresentationAtoms(input: { return { entry, connection: presentEnvironmentConnection(state), - serverConfig: get(input.configValueAtom(environmentId)), + serverConfig: get(input.serverConfigValueAtom(environmentId)), } satisfies EnvironmentPresentation; }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 549942be277..ca804c13809 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -1,6 +1,6 @@ import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; -import type { UnifiedSettings } from "@t3tools/contracts/settings"; +import type { ClientSettings } from "@t3tools/contracts/settings"; import type { EnvironmentProject } from "./models.ts"; import { normalizeProjectPathForComparison } from "./projects.ts"; @@ -12,7 +12,7 @@ export interface ProjectGroupingSettings { export type ProjectGroupingMode = SidebarProjectGroupingMode; -export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { +export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { return { sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 23bb7bff2a9..eb784183793 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -118,9 +118,21 @@ export function createServerEnvironmentAtoms( return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); }); + const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe( + Atom.withLabel(`environment-data:server:settings:${environmentId}`), + ), + ); + const providersValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( + Atom.withLabel(`environment-data:server:providers:${environmentId}`), + ), + ); return { configValueAtom, + settingsValueAtom, + providersValueAtom, traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:trace-diagnostics", tag: WS_METHODS.serverGetTraceDiagnostics, diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 97a637a9c5a..84e9dbdd8eb 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -26,7 +26,7 @@ export function initialConfigOption( export function createEnvironmentSessionAtoms( runtime: Atom.AtomRuntime, ) { - const configAtom = Atom.family((environmentId: EnvironmentId) => + const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( followStreamInEnvironment( environmentId, @@ -49,10 +49,15 @@ export function createEnvironmentSessionAtoms( ), ); - const configValueAtom = Atom.family((environmentId: EnvironmentId) => + // This is only the bootstrap config captured when a transport session is + // established. Consumers that need current provider/settings state must use + // createServerEnvironmentAtoms(...).configValueAtom instead. + const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ServerConfig | null => Option.getOrNull( - Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()), + Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () => + Option.none(), + ), ), ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), ); @@ -80,8 +85,8 @@ export function createEnvironmentSessionAtoms( ); return { - configAtom, - configValueAtom, + initialConfigAtom, + initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, }; diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts index fcde2ad7d80..f1326e0a5cb 100644 --- a/packages/client-runtime/src/state/shell.test.ts +++ b/packages/client-runtime/src/state/shell.test.ts @@ -75,7 +75,7 @@ function makeHarness() { }); const serverConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom, - configValueAtom: configAtoms, + serverConfigValueAtom: configAtoms, }); return { diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a697a4e2a6b..428e99b76d0 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -268,13 +268,13 @@ export function createEnvironmentShellSummaryAtom(input: { export function createEnvironmentServerConfigsAtom(input: { readonly catalogValueAtom: Atom.Atom; - readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; }) { let previousServerConfigs = EMPTY_SERVER_CONFIGS; return Atom.make((get) => { const next = new Map(); for (const environmentId of get(input.catalogValueAtom).entries.keys()) { - const config = get(input.configValueAtom(environmentId)); + const config = get(input.serverConfigValueAtom(environmentId)); if (config !== null) { next.set(environmentId, config); } From b0a3a504481ab4d8f2da0343e5a4258c10d03c73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:12:46 -0700 Subject: [PATCH 059/142] [codex] Refactor project and workspace Effect services (#3190) Co-authored-by: codex --- .../OrchestrationEngineHarness.integration.ts | 10 +- apps/server/src/assets/AssetAccess.test.ts | 14 +- apps/server/src/assets/AssetAccess.ts | 10 +- apps/server/src/bin.test.ts | 12 +- apps/server/src/cli/connect.ts | 5 +- apps/server/src/cli/project.ts | 11 +- apps/server/src/cloud/http.test.ts | 2 +- apps/server/src/cloud/http.ts | 6 +- .../{Layers => }/ServerEnvironment.test.ts | 13 +- .../{Layers => }/ServerEnvironment.ts | 37 ++-- .../ServerEnvironmentLabel.test.ts | 4 +- .../{Layers => }/ServerEnvironmentLabel.ts | 4 +- .../environment/Services/ServerEnvironment.ts | 12 -- apps/server/src/git/GitManager.test.ts | 13 +- apps/server/src/git/GitManager.ts | 2 +- apps/server/src/http.ts | 2 +- .../server/src/mcp/McpSessionRegistry.test.ts | 6 +- apps/server/src/mcp/McpSessionRegistry.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 12 +- .../Layers/OrchestrationEngine.test.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 4 +- .../Layers/ProjectionSnapshotQuery.test.ts | 7 +- .../Layers/ProjectionSnapshotQuery.ts | 4 +- .../Layers/ProviderCommandReactor.test.ts | 6 +- .../Layers/ProviderRuntimeIngestion.test.ts | 6 +- apps/server/src/orchestration/Normalizer.ts | 4 +- .../Layers/ProjectSetupScriptRunner.test.ts | 165 --------------- .../Layers/ProjectSetupScriptRunner.ts | 103 ---------- .../ProjectFaviconResolver.test.ts | 13 +- .../{Layers => }/ProjectFaviconResolver.ts | 47 +++-- .../project/ProjectSetupScriptRunner.test.ts | 148 ++++++++++++++ .../src/project/ProjectSetupScriptRunner.ts | 179 ++++++++++++++++ .../RepositoryIdentityResolver.test.ts | 36 ++-- .../RepositoryIdentityResolver.ts | 177 ++++++++-------- .../Services/ProjectFaviconResolver.ts | 30 --- .../Services/ProjectSetupScriptRunner.ts | 44 ---- .../Services/RepositoryIdentityResolver.ts | 12 -- .../src/relay/AgentAwarenessRelay.test.ts | 10 +- apps/server/src/relay/AgentAwarenessRelay.ts | 2 +- apps/server/src/server.test.ts | 83 ++++---- apps/server/src/server.ts | 30 +-- apps/server/src/serverRuntimeStartup.test.ts | 5 +- apps/server/src/serverRuntimeStartup.ts | 19 +- .../workspace/Layers/WorkspaceFileSystem.ts | 123 ----------- .../src/workspace/Layers/WorkspacePaths.ts | 107 ---------- .../workspace/Services/WorkspaceFileSystem.ts | 70 ------- .../src/workspace/Services/WorkspacePaths.ts | 103 ---------- .../src/workspace/WorkspaceEntries.test.ts | 15 +- apps/server/src/workspace/WorkspaceEntries.ts | 135 ++++++++----- .../{Layers => }/WorkspaceFileSystem.test.ts | 47 +++-- .../src/workspace/WorkspaceFileSystem.ts | 175 ++++++++++++++++ .../{Layers => }/WorkspacePaths.test.ts | 17 +- apps/server/src/workspace/WorkspacePaths.ts | 191 ++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 105 +++++----- apps/server/src/ws.ts | 18 +- 55 files changed, 1211 insertions(+), 1220 deletions(-) rename apps/server/src/environment/{Layers => }/ServerEnvironment.test.ts (90%) rename apps/server/src/environment/{Layers => }/ServerEnvironment.ts (72%) rename apps/server/src/environment/{Layers => }/ServerEnvironmentLabel.test.ts (97%) rename apps/server/src/environment/{Layers => }/ServerEnvironmentLabel.ts (96%) delete mode 100644 apps/server/src/environment/Services/ServerEnvironment.ts delete mode 100644 apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts delete mode 100644 apps/server/src/project/Layers/ProjectSetupScriptRunner.ts rename apps/server/src/project/{Layers => }/ProjectFaviconResolver.test.ts (82%) rename apps/server/src/project/{Layers => }/ProjectFaviconResolver.ts (75%) create mode 100644 apps/server/src/project/ProjectSetupScriptRunner.test.ts create mode 100644 apps/server/src/project/ProjectSetupScriptRunner.ts rename apps/server/src/project/{Layers => }/RepositoryIdentityResolver.test.ts (88%) rename apps/server/src/project/{Layers => }/RepositoryIdentityResolver.ts (53%) delete mode 100644 apps/server/src/project/Services/ProjectFaviconResolver.ts delete mode 100644 apps/server/src/project/Services/ProjectSetupScriptRunner.ts delete mode 100644 apps/server/src/project/Services/RepositoryIdentityResolver.ts delete mode 100644 apps/server/src/workspace/Layers/WorkspaceFileSystem.ts delete mode 100644 apps/server/src/workspace/Layers/WorkspacePaths.ts delete mode 100644 apps/server/src/workspace/Services/WorkspaceFileSystem.ts delete mode 100644 apps/server/src/workspace/Services/WorkspacePaths.ts rename apps/server/src/workspace/{Layers => }/WorkspaceFileSystem.test.ts (79%) create mode 100644 apps/server/src/workspace/WorkspaceFileSystem.ts rename apps/server/src/workspace/{Layers => }/WorkspacePaths.test.ts (88%) create mode 100644 apps/server/src/workspace/WorkspacePaths.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index fa388ba052f..292b267e124 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -46,7 +46,7 @@ import { import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; -import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; @@ -72,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"; @@ -348,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( @@ -378,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), diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 29b1db25118..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)); @@ -127,7 +127,7 @@ describe("AssetAccess", () => { 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"; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index ae5086e9735..cf3c40f57c7 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -22,8 +22,8 @@ import { import { 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 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 = ``; @@ -103,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)); @@ -130,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; @@ -202,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 ( diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 64d366468f9..d71bc83f94e 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. -import * as NodeHttp from "node:http"; +import { createServer } from "node:http"; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,12 +25,12 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna 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"; @@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) => const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) => Layer.mergeAll( OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceLayerLive), ), - WorkspacePathsLive, + WorkspacePaths.layer, ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config))); const readPersistedSnapshot = (baseDir: string) => @@ -124,7 +124,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ), Layer.provideMerge(makeProjectPersistenceLayer(config)), Layer.provideMerge( - NodeHttpServer.layer(NodeHttp.createServer, { + NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0, }), diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 51582965913..314680b0d80 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -32,8 +32,7 @@ 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 * as ServerConfig from "../config.ts"; -import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -301,7 +300,7 @@ 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), diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index eec7f3f5541..d52d5b214d8 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -31,14 +31,13 @@ import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEng 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 * 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 * as 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), ), ); @@ -301,7 +300,7 @@ 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(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 3a8586f150a..58274c9d708 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -9,7 +9,7 @@ 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 * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 86716b69a35..64c87f3487c 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,4 +1,4 @@ -import * as NodeCrypto from "node:crypto"; +import { createPublicKey } from "node:crypto"; import { AuthRelayReadScope, AuthRelayWriteScope, @@ -55,7 +55,7 @@ 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 * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, @@ -152,7 +152,7 @@ function validateCloudMintPublicKey( publicKey: string, ): Effect.Effect { return Effect.try({ - try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), + try: () => createPublicKey(publicKey.replace(/\\n/g, "\n")), catch: () => new EnvironmentHttpBadRequestError({ message: "Cloud mint public key must be a valid Ed25519 public key.", diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts similarity index 90% rename from apps/server/src/environment/Layers/ServerEnvironment.test.ts rename to apps/server/src/environment/ServerEnvironment.test.ts index 3bb96a83e1c..3b0cef13bf9 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as nodePath from "node:path"; +import { dirname } from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -8,12 +8,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; -import * as ServerConfig from "../../config.ts"; -import * as ServerEnvironment from "../Services/ServerEnvironment.ts"; -import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "./ServerEnvironment.ts"; const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); const makeServerConfig = Effect.fn(function* (baseDir: string) { const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); @@ -77,7 +76,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; - yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.makeDirectory(dirname(environmentIdPath), { recursive: true }); yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); const writeAttempts: string[] = []; const failingFileSystemLayer = FileSystem.layerNoop({ @@ -113,7 +112,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { return yield* serverEnvironment.getDescriptor; }).pipe( Effect.provide( - ServerEnvironmentLive.pipe( + ServerEnvironment.layer.pipe( Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), ), ), diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts similarity index 72% rename from apps/server/src/environment/Layers/ServerEnvironment.ts rename to apps/server/src/environment/ServerEnvironment.ts index fd4f6baab1a..433a9d3f02a 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,17 +1,25 @@ 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 { 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 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 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": @@ -38,10 +46,10 @@ function platformArch( } } -export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; @@ -77,9 +85,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ - cwdBaseName, - }); + const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -94,12 +100,15 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function }, }; - return { + return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), - } satisfies ServerEnvironmentShape; + }); }); -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( - Layer.provide(ProcessRunnerLive), -); +/** + * 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 97% rename from apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts rename to apps/server/src/environment/ServerEnvironmentLabel.test.ts index 14580369a78..bc30bd0ce19 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -2,12 +2,12 @@ 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 * as ProcessRunner from "../../processRunner.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; const runMock = vi.fn(); 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 2b296e5f3fa..3ff9a42390e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -20,7 +20,6 @@ import type { } from "@t3tools/contracts"; import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; -import * as GitManager from "./GitManager.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -28,8 +27,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; -import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts"; +import * as GitManager from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -3215,10 +3215,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, setupScriptRunner: { - runForThread: () => + runForThread: (input) => Effect.fail( - new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ - message: "terminal start failed", + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: new Error("terminal start failed"), }), ), }, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c57c814f437..88eb0e21282 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -43,7 +43,7 @@ import { import { GitManagerError } from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 032fd501b01..0528d5e523d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -39,7 +39,7 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal, } from "./auth/http.ts"; -import * as 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"; diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 7616affaafd..a91d98febd8 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -4,7 +4,7 @@ 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"); @@ -14,7 +14,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) => serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], }); const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); -const fakeEnvironment = ServerEnvironment.of({ +const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), }); @@ -28,7 +28,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => }) .pipe( Effect.provideService(HttpServer.HttpServer, httpServer), - Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), Effect.provide(NodeServices.layer), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c15480310d5..de9dc958415 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"; @@ -75,7 +75,7 @@ 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() }); @@ -194,7 +194,7 @@ const make = Effect.acquireRelease( export const layer: Layer.Layer< McpSessionRegistry, never, - Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer + Crypto.Crypto | ServerEnvironment.ServerEnvironment | HttpServer.HttpServer > = Layer.effect(McpSessionRegistry, make); export const issueActiveMcpCredential = ( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 4bb5afbb476..07c543264f7 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -34,7 +34,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -56,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); @@ -294,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), ); @@ -333,11 +333,11 @@ describe("CheckpointReactor", () => { 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), 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 0e399f03ab8..8041bc66dd3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -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"; @@ -335,11 +335,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( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 64955590235..2aaa7ea9a33 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -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"; @@ -226,11 +226,11 @@ describe("ProviderRuntimeIngestion", () => { 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/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/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index 91d39a3c1ea..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 * as TerminalManager from "../../terminal/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.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.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 3c8772641be..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 * as TerminalManager from "../../terminal/Manager.ts"; -import { - type ProjectSetupScriptRunnerShape, - ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, -} from "../Services/ProjectSetupScriptRunner.ts"; - -const makeProjectSetupScriptRunner = Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const terminalManager = yield* TerminalManager.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..d7a1bd15c58 --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,148 @@ +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 ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectSetupScriptRunner 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.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 }))); + }, + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts new file mode 100644 index 00000000000..57540088128 --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -0,0 +1,179 @@ +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 setup script project was not found for thread '${this.threadId}'.`; + } +} + +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") {} + +const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError); + +function operationError( + input: ProjectSetupScriptRunnerInput, + operation: ProjectSetupScriptOperationError["operation"], + cause: unknown, +): ProjectSetupScriptOperationError { + return new ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation, + cause, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }); +} + +function mapRunnerError( + input: ProjectSetupScriptRunnerInput, + operation: ProjectSetupScriptOperationError["operation"], +) { + return Effect.mapError((cause: unknown) => + isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause), + ); +} + +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 projectById = input.projectId + ? yield* projectionSnapshotQuery + .getProjectShellById(ProjectId.make(input.projectId)) + .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + : null; + const project = + projectById ?? + (input.projectCwd + ? yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.projectCwd) + .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + : null); + + if (!project) { + return yield* new ProjectSetupScriptProjectNotFoundError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }); + } + + 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(mapRunnerError(input, "openTerminal")); + yield* terminalManager + .write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }) + .pipe(mapRunnerError(input, "writeCommand")); + + 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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4d31bb26137..eb2b2c3f2fa 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -1,4 +1,4 @@ -import * as NodeCrypto from "node:crypto"; +import { generateKeyPairSync } from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -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, @@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("signs the activity publish JWT and rejects tampering", async () => { - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const keyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -494,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), }), @@ -642,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 8528b4b0c8e..4e036e3ea0e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -41,7 +41,7 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; -import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 76824af73e3..fd69c610df4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,7 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeCrypto from "node:crypto"; +import { generateKeyPairSync, type KeyObject, sign } from "node:crypto"; import { AuthAccessTokenType, @@ -89,13 +89,13 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; -import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; -import * as ServerEnvironment from "./environment/Services/ServerEnvironment.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"; @@ -485,17 +485,17 @@ const buildAppUnderTest = (options?: { ...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), @@ -950,14 +950,14 @@ const makeDpopProof = (input: { readonly iat: number; readonly accessToken?: string; readonly jti?: string; - readonly privateKey?: NodeCrypto.KeyObject; + readonly privateKey?: KeyObject; readonly publicJwk?: DpopPublicJwk; }) => { const keyPair = input.privateKey && input.publicJwk ? { privateKey: input.privateKey, publicJwk: input.publicJwk } : (() => { - const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256", }); return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; @@ -978,7 +978,7 @@ const makeDpopProof = (input: { ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), }), ).toString("base64url"); - const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + const signature = sign("sha256", Buffer.from(`${header}.${payload}`), { key: keyPair.privateKey, dsaEncoding: "ieee-p1363", }).toString("base64url"); @@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: { const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); const signingInput = `${header}.${encodedPayload}`; return { - proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, }; }; @@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: { const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); const signingInput = `${header}.${encodedPayload}`; return { - proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, }; }; @@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const cloudKeyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -4447,10 +4447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "ProjectSearchEntriesError"); - assertInclude( - result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", - ); + assert.equal(result.failure.message, "Failed to search workspace entries."); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6096,13 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); const runForThread = vi.fn( ( - _: Parameters< + input: Parameters< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] >[0], ) => Effect.fail( - new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ - message: "pty unavailable", + new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId: input.threadId, + worktreePath: input.worktreePath, + operation: "openTerminal", + cause: new Error("pty unavailable"), }), ), ); @@ -6177,7 +6177,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: "pty unavailable", + detail: + "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 987ba83deae..81d0013b20c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,11 +52,11 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.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"; @@ -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), @@ -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( @@ -315,8 +315,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( 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( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 2109f4c5458..e331f0cd4d6 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -57,7 +57,10 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => yield* commandGate.failCommandReady( new ServerRuntimeStartup.ServerRuntimeStartupError({ - stage: "command-readiness", + mode: "web", + host: "127.0.0.1", + port: 3773, + cause: new Error("test startup failure"), }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 35ac5a06fc9..cbdf58c4d67 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -30,8 +30,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { @@ -44,15 +44,14 @@ import { export class ServerRuntimeStartupError extends Schema.TaggedErrorClass()( "ServerRuntimeStartupError", { - stage: Schema.Literal("command-readiness"), - cause: Schema.optional(Schema.Defect()), + mode: ServerConfig.RuntimeMode, + host: Schema.NullOr(Schema.String), + port: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - switch (this.stage) { - case "command-readiness": - return "Server runtime startup failed before command readiness."; - } + return "Server runtime startup failed before command readiness."; } } @@ -289,7 +288,7 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; @@ -417,7 +416,9 @@ const make = Effect.gen(function* () { const startupExit = yield* Effect.exit(startup); if (Exit.isFailure(startupExit)) { const error = new ServerRuntimeStartupError({ - stage: "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 }); 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..7d6005f030d 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -9,18 +9,18 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -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"; 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 +363,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'.", + ); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bf9a51c74db..aafd6ffd75a 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -20,29 +20,72 @@ 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", { cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: Schema.Literals([ + "workspaceEntries.normalizeWorkspaceRoot", + "workspaceEntries.search", + "workspaceEntries.list", + ]), + cause: Schema.Defect(), }, -) {} +) { + override get message(): string { + return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`; + } +} -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", +export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesWindowsPathUnsupportedError", { cwd: Schema.optional(Schema.String), partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + platform: Schema.String, }, -) {} +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`; + } +} + +export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesCurrentProjectRequiredError", + { + partialPath: Schema.String, + }, +) { + override get message(): string { + return `A current project is required to browse relative workspace path '${this.partialPath}'.`; + } +} + +export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesReadDirectoryError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + parentPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`; + } +} + +export const WorkspaceEntriesBrowseError = Schema.Union([ + WorkspaceEntriesWindowsPathUnsupportedError, + WorkspaceEntriesCurrentProjectRequiredError, + WorkspaceEntriesReadDirectoryError, +]); +export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; export class WorkspaceEntries extends Context.Service< WorkspaceEntries, @@ -62,46 +105,40 @@ export class WorkspaceEntries extends Context.Service< function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return NodeOS.homedir(); + return homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); + return path.join(homedir(), input.slice(2)); } 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; @@ -115,7 +152,6 @@ const make = Effect.gen(function* () { new WorkspaceEntriesError({ cwd, operation: "workspaceEntries.normalizeWorkspaceRoot", - detail: cause.message, cause, }), ), @@ -156,13 +192,12 @@ const make = Effect.gen(function* () { const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); const dirents = yield* Effect.tryPromise({ - try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), + try: () => 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( @@ -215,7 +250,6 @@ const make = Effect.gen(function* () { new WorkspaceEntriesError({ cwd: input.cwd, operation: "workspaceEntries.search", - detail: cause.message, cause, }), ), @@ -236,7 +270,6 @@ const make = Effect.gen(function* () { new WorkspaceEntriesError({ cwd: input.cwd, operation: "workspaceEntries.list", - detail: cause.message, cause, }), ), diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts similarity index 79% rename from apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts rename to apps/server/src/workspace/WorkspaceFileSystem.test.ts index 5a4ec54686e..aa2dabb3337 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; @@ -106,7 +105,13 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i .readFile({ cwd, relativePath: "linked-secret.txt" }) .pipe(Effect.flip); - expect(error.message).toContain("resolves outside the project root"); + expect(error.message).toBe( + `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, + ); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe( + "Workspace file path resolves outside the project root.", + ); }), ); }); @@ -114,7 +119,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 +140,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 +165,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..48e02c89cae --- /dev/null +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -0,0 +1,175 @@ +// @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 { open, realpath } 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 WorkspaceFileSystemError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemError", + { + cwd: Schema.String, + relativePath: Schema.optional(Schema.String), + operation: Schema.Literals([ + "workspaceFileSystem.readFile", + "workspaceFileSystem.makeDirectory", + "workspaceFileSystem.writeFile", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; + return `Workspace file operation '${this.operation}' failed for ${target}.`; + } +} + +/** 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, + }); + + return yield* Effect.tryPromise({ + try: async () => { + const [realWorkspaceRoot, realTargetPath] = await Promise.all([ + realpath(input.cwd), + 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 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", + 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 WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.makeDirectory", + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.writeFile", + 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..8b6b685524b --- /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 { homedir } 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 homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(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 0b25b25f6f6..935dd47cc85 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -79,15 +79,15 @@ import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; -import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts"; -import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.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/Services/ProjectSetupScriptRunner.ts"; -import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; -import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; +import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; @@ -1190,7 +1190,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, + message: "Failed to search workspace entries.", cause, }), ), @@ -1204,7 +1204,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${cause.detail}`, + message: "Failed to list workspace entries.", cause, }), ), @@ -1218,7 +1218,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError((cause) => { const message = isWorkspacePathOutsideRootError(cause) ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${cause.detail}`; + : "Failed to read workspace file."; return new ProjectReadFileError({ message, cause }); }), ), @@ -1251,7 +1251,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: cause.detail, + message: "Failed to browse the filesystem.", cause, }), ), From c00e721c047ab0b832401196e64df26431fd9c98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:17:29 -0700 Subject: [PATCH 060/142] [codex] Refactor client runtime Effect services (#3200) Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 4 +- .../liveActivityPreferences.ts | 4 +- .../remoteRegistration.test.ts | 4 +- .../agent-awareness/remoteRegistration.ts | 30 +- .../src/features/cloud/CloudAuthProvider.tsx | 4 +- .../features/cloud/linkEnvironment.test.ts | 18 +- .../src/features/cloud/linkEnvironment.ts | 42 +- .../src/features/cloud/managedRelayLayer.ts | 54 +- .../features/cloud/managedRelayTokenStore.ts | 13 +- apps/mobile/src/lib/runtime.ts | 22 +- apps/mobile/src/state/relay.ts | 2 +- apps/web/src/cloud/linkEnvironment.test.ts | 18 +- apps/web/src/cloud/linkEnvironment.ts | 30 +- apps/web/src/cloud/managedAuth.tsx | 6 +- apps/web/src/cloud/managedRelayLayer.ts | 54 +- apps/web/src/cloud/managedRelayState.ts | 8 +- apps/web/src/lib/runtime.ts | 22 +- apps/web/src/state/environments.ts | 3 +- apps/web/src/state/relay.ts | 2 +- .../src/authorization/layer.test.ts | 1 - .../client-runtime/src/connection/errors.ts | 46 +- .../src/connection/resolver.test.ts | 8 +- .../src/relay/discovery.test.ts | 13 +- packages/client-runtime/src/relay/index.ts | 4 +- .../src/relay/managedRelay.test.ts | 64 +- .../client-runtime/src/relay/managedRelay.ts | 1207 +++++++++-------- .../src/relay/managedRelayState.test.ts | 17 +- .../src/relay/managedRelayState.ts | 10 +- .../src/state/relayDiscovery.ts | 17 +- 29 files changed, 962 insertions(+), 765 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index ec50e4ae9ce..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,7 @@ 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/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; @@ -35,7 +35,7 @@ const connection: SavedRemoteConnection = { }; const testLayer = Layer.mergeAll( - Layer.succeed(ManagedRelayClient, null as never), + Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index a522129d40d..8f73ffdf65e 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; @@ -11,7 +11,7 @@ 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 }), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 257b914fe97..43d62b81622 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -9,7 +9,7 @@ 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/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; @@ -158,7 +158,7 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO } idlePasses = 0; const exit = yield* Effect.exit( - pending.operation as Effect.Effect, + pending.operation as Effect.Effect, ); yield* Effect.sync(() => { pending.resolve(exit); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 24e6a094661..98e38c74055 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -9,7 +9,7 @@ import { type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, settleAsyncResult, @@ -175,7 +175,7 @@ const relayToken = Effect.gen(function* () { function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, expectedGeneration: number, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled before relay request", { @@ -198,7 +198,7 @@ function registerDeviceWithRelay( return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; logRegistrationDebug("relay device registration request started", { expectedGeneration, }); @@ -215,7 +215,7 @@ function registerDeviceWithRelay( 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({ @@ -227,7 +227,7 @@ function unregisterDeviceWithRelay(input: { return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.unregisterDevice({ clerkToken: token, deviceId: input.deviceId, @@ -237,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; @@ -246,7 +246,7 @@ function registerLiveActivityWithRelay( return false; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.registerLiveActivity({ clerkToken: token, payload: body, @@ -274,7 +274,7 @@ function logRegistrationDebug(context: string, details?: unknown): void { } function runRegistrationInBackground( - operation: Effect.Effect, + operation: Effect.Effect, context: string, ): void { void (async () => { @@ -370,7 +370,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri function registerDevice( input: DeviceRegistrationInput = {}, expectedGeneration = deviceRegistrationGeneration, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { logRegistrationDebug("device registration skipped; platform does not support it"); @@ -411,7 +411,7 @@ function registerDevice( function registerDeviceForCurrentUser( pushToStartToken?: string, -): Effect.Effect { +): Effect.Effect { return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); } @@ -485,7 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void { export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return registerDeviceForCurrentUser().pipe( Effect.catch((error) => @@ -515,7 +515,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { export function unregisterAgentAwarenessDeviceForCurrentUser( tokenProvider: () => Promise, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), @@ -536,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; @@ -588,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(), @@ -624,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/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index b8349fc60d3..c89aeb9249a 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,6 +1,6 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { reportAtomCommandResult, settleAsyncResult, @@ -22,7 +22,7 @@ import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publi function resetManagedRelayTokenCache() { return settleAsyncResult(() => runtime.runPromiseExit( - ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), ), ); } diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index aa1071fd3c2..b9ab3aeab05 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,11 +4,7 @@ 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, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; @@ -62,8 +58,8 @@ const createProofMock = vi.fn( 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), }), @@ -73,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)), @@ -81,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() { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 680e6e80cfa..a77ca628978 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -25,11 +25,7 @@ import { import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - ManagedRelayClient, - type ManagedRelayClientError, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -156,13 +152,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { } function decodedRelayClientError(message: string) { - return (cause: ManagedRelayClientError) => { - const relayError = cause.relayError; + 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, - ...(cause.traceId ? { traceId: cause.traceId } : {}), + ...(traceId ? { traceId } : {}), }); }; } @@ -261,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({ @@ -270,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."), @@ -353,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({ @@ -374,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, @@ -413,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.forEach( input.environments, @@ -445,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const environments = yield* listCloudEnvironments(input); @@ -473,7 +475,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag }) { yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient @@ -514,7 +516,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag 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", @@ -555,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, @@ -570,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 6678d13047e..2da1fa9157c 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer as makeManagedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime/relay"; +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"; @@ -12,34 +8,54 @@ import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; const relayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const loadProofKey = yield* Effect.cached( loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), ); - return ManagedRelayDpopSigner.of({ + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadProofKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "expo-secure-store", + cause: error, + }), + ), Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")( - function* (input) { - const proofKey = yield* loadProofKey; - return yield* createDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }, - 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 managedRelayClientLayer = (relayUrl: string) => - makeManagedRelayClientLayer({ + ManagedRelay.layer({ relayUrl, clientId: RelayMobileClientId, accessTokenStore: managedRelayAccessTokenStore, diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 54153a426a1..460c71c1fa7 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -1,7 +1,4 @@ -import { - type ManagedRelayAccessTokenCacheEntry, - type ManagedRelayAccessTokenStore, -} from "@t3tools/client-runtime/relay"; +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"; @@ -60,7 +57,7 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({ }).pipe( Effect.flatMap((encoded) => encoded === null - ? Effect.succeed>([]) + ? Effect.succeed>([]) : decodeManagedRelayAccessTokenCache(encoded).pipe( Effect.map((cache) => cache.entries), Effect.mapError(storeError("Persisted relay access tokens are invalid.")), @@ -68,7 +65,9 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({ ), ); -const saveManagedRelayAccessTokens = (entries: ReadonlyArray) => +const saveManagedRelayAccessTokens = ( + entries: ReadonlyArray, +) => encodeManagedRelayAccessTokenCache({ version: MANAGED_RELAY_TOKEN_CACHE_VERSION, entries, @@ -87,7 +86,7 @@ const clearManagedRelayAccessTokens = Effect.tryPromise({ catch: storeError("Could not clear persisted relay access tokens."), }); -export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = { +export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: loadManagedRelayAccessTokens.pipe( Effect.tapError(logStoreFailure("load")), Effect.orElseSucceed(() => []), diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index bb8c1e8398a..f760bef3459 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -15,7 +15,17 @@ function configuredRelayUrl(): string { const httpClientLayer = remoteHttpClientLayer(fetch); -export const runtimeLayer = Layer.merge( +type RuntimeLayerSource = + | ReturnType + | typeof Socket.layerWebSocketConstructorGlobal + | typeof cryptoLayer + | typeof httpClientLayer + | typeof tracingLayer; + +export const runtimeLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.merge( managedRelayClientLayer(configuredRelayUrl()), Socket.layerWebSocketConstructorGlobal, ).pipe( @@ -24,6 +34,12 @@ export const runtimeLayer = Layer.merge( Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), ); -export const runtime = ManagedRuntime.make(runtimeLayer); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); -export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts index f078572736b..3cbac7a1875 100644 --- a/apps/mobile/src/state/relay.ts +++ b/apps/mobile/src/state/relay.ts @@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st import { connectionAtomRuntime } from "../connection/runtime"; -export const relayEnvironmentDiscovery = +export const relayEnvironmentDiscovery: ReturnType = createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 51251975557..f823016ddf0 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -21,11 +21,7 @@ import { } from "@t3tools/client-runtime/connection"; import { type RpcSession } from "@t3tools/client-runtime/rpc"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; -import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; @@ -60,8 +56,8 @@ vi.mock("./relayClientInstallDialog", () => ({ const createProof = vi.fn(() => Effect.succeed("dpop-proof")); const dpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("thumbprint"), createProof, }), @@ -71,7 +67,7 @@ function relayLayer() { const http = remoteHttpClientLayer(globalThis.fetch); return Layer.mergeAll( http, - managedRelayClientLayer({ + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayWebClientId, }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)), @@ -129,7 +125,11 @@ function services(options?: Parameters[0]) { } function withServices( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry + >, options?: Parameters[0], ) { return effect.pipe(Effect.provide(services(options))); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a8f410acdfa..20bf75c7d6d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -25,7 +25,7 @@ import { import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { readPrimaryEnvironmentDescriptor, @@ -164,13 +164,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { } function decodedRelayClientError(message: string) { - return (cause: ManagedRelayClientError) => { - const relayError = cause.relayError; + 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, - ...(cause.traceId ? { traceId: cause.traceId } : {}), + ...(traceId ? { traceId } : {}), }); }; } @@ -268,7 +270,7 @@ export function listManagedCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -277,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, @@ -299,7 +301,7 @@ export function listCloudDevices(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!relayUrl()) { @@ -307,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) => @@ -351,7 +353,11 @@ export function updatePrimaryCloudPreferences(input: { 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 client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* client.connect @@ -360,7 +366,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, @@ -383,7 +389,7 @@ export function linkPrimaryEnvironmentToCloud(input: { }): Effect.Effect< void, CloudEnvironmentLinkError, - EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -392,7 +398,7 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index a708f6df0e7..2f631214501 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/react"; -import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { reportAtomCommandResult, settleAsyncResult, @@ -64,7 +64,9 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac removeRelayEnvironments(), settleAsyncResult(() => runtime.runPromiseExit( - ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.resetTokenCache), + ), ), ), ]); diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 53a3e24c6d8..52f9b6496c9 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer as makeManagedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime/relay"; +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"; @@ -18,7 +14,7 @@ import { } from "./dpop"; export const relayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const keyLoadSemaphore = yield* Semaphore.make(1); @@ -40,27 +36,47 @@ export const relayDpopSignerLayer = Layer.effect( }), ); - return ManagedRelayDpopSigner.of({ + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + keyStore: "indexed-db", + cause: error, + }), + ), Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), ), - createProof: Effect.fn("web.managedRelayDpopSigner.createProof")( - function* (input) { - const proofKey = yield* loadOrCreateBrowserDpopKey; - return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }, - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), - ), + 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( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); export const managedRelayClientLayer = (relayUrl: string) => - makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( + 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 0a1ec61a3cc..5f29c121dbc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, - ManagedRelayClient, + ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; @@ -20,8 +20,10 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; const managedRelayAtomRuntime = Atom.runtime( Layer.effect( - ManagedRelayClient, - runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))), + ManagedRelay.ManagedRelayClient, + runtime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)), + ), ), ); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index e4bea61f143..a4d87a7ae01 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -24,6 +24,13 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig( client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(httpClientLayer)); +type RuntimeLayerSource = + | typeof httpClientLayer + | typeof browserCryptoLayer + | typeof Socket.layerWebSocketConstructorGlobal + | typeof relayTracingLayer + | ReturnType; + export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( @@ -47,7 +54,10 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const runtimeLayer = Layer.mergeAll( +export const runtimeLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, @@ -57,6 +67,12 @@ export const runtimeLayer = Layer.mergeAll( ), ); -export const runtime = ManagedRuntime.make(runtimeLayer); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); -export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 211c981c5f6..443e99b84cd 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -3,6 +3,7 @@ import { connectionCatalogDisplayUrl, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; +import { Discovery } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { useMemo } from "react"; @@ -81,7 +82,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; } -export function useRelayEnvironmentDiscovery() { +export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } diff --git a/apps/web/src/state/relay.ts b/apps/web/src/state/relay.ts index f078572736b..3cbac7a1875 100644 --- a/apps/web/src/state/relay.ts +++ b/apps/web/src/state/relay.ts @@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st import { connectionAtomRuntime } from "../connection/runtime"; -export const relayEnvironmentDiscovery = +export const relayEnvironmentDiscovery: ReturnType = createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index d950c241d50..1d2c6c6cca7 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -118,7 +118,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( createProof: (proofInput) => Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( Effect.as(`proof:${proofInput.url}`), - Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })), ), }); const layer = RemoteEnvironmentAuthorization.layer.pipe( diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index 5d9d361c06d..f70e41adfe7 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -74,21 +74,39 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError } export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { - if (error.relayError) { - return relayProtectedError(error.relayError); - } - if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { - return new ConnectionTransientError({ - reason: "timeout", - detail: error.message, - ...(error.traceId ? { traceId: error.traceId } : {}), - }); + switch (error._tag) { + case "ManagedRelayRequestFailedError": + if (error.relayError) { + return relayProtectedError(error.relayError); + } + return new ConnectionTransientError({ + reason: "relay-unavailable", + detail: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); + case "ManagedRelayRequestTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + detail: error.message, + }); + case "ManagedRelayUrlInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + detail: error.message, + }); + case "ManagedRelayAccessTokenScopesUnexpectedError": + return new ConnectionBlockedError({ + reason: "permission", + detail: error.message, + }); + case "ManagedRelayDpopKeyLoadError": + case "ManagedRelayTokenProofCreationError": + case "ManagedRelayRequestProofCreationError": + return new ConnectionBlockedError({ + reason: "authentication", + detail: error.message, + }); } - return new ConnectionTransientError({ - reason: "relay-unavailable", - detail: error.message, - ...(error.traceId ? { traceId: error.traceId } : {}), - }); } export function mapRemoteEnvironmentError( diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 7d165b22ea2..0469e459d16 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -446,11 +446,9 @@ describe("ConnectionResolver", () => { const brokerLayer = yield* makeDependencies({ connectEnvironment: () => Effect.fail( - new ManagedRelay.ManagedRelayClientError({ - message: "Relay timed out.", - cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ - message: "Relay timed out.", - }), + new ManagedRelay.ManagedRelayRequestTimeoutError({ + activity: "Relay environment connection", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), }); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index e05302195db..6bdc7798fb2 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -258,11 +258,9 @@ describe("RelayEnvironmentDiscovery", () => { relayUrl: "https://relay.example.test", listEnvironments: () => Effect.fail( - new ManagedRelay.ManagedRelayClientError({ - message: "Relay environment listing timed out.", - cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ - message: "Relay environment listing timed out.", - }), + new ManagedRelay.ManagedRelayRequestTimeoutError({ + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), getEnvironmentStatus: () => Effect.die("unused"), @@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, - new ManagedRelay.ManagedRelayClientError({ - message: "Relay environment listing failed.", + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "list relay-managed environments", + cause: new Error("Relay request failed."), }), ); yield* discovery.refresh; diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts index 8e76367c601..76f75535304 100644 --- a/packages/client-runtime/src/relay/index.ts +++ b/packages/client-runtime/src/relay/index.ts @@ -1,3 +1,3 @@ -export * from "./discovery.ts"; -export * from "./managedRelay.ts"; +export * as Discovery from "./discovery.ts"; +export * as ManagedRelay from "./managedRelay.ts"; export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 9c08c374bcd..278c205883f 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -8,31 +8,24 @@ import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; -import { - MANAGED_RELAY_REQUEST_TIMEOUT_MS, - ManagedRelayClient, - ManagedRelayDpopSigner, - managedRelayClientLayer, - type ManagedRelayAccessTokenCacheEntry, - type ManagedRelayAccessTokenStore, - type ManagedRelayDpopProofInput, -} from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; function managedRelayTestLayer( fetchFn: typeof globalThis.fetch, relayUrl = "https://relay.example.test", - accessTokenStore?: ManagedRelayAccessTokenStore, + accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore, ) { const httpClientLayer = remoteHttpClientLayer(fetchFn); const signerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("client-thumbprint"), - createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), + createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) => + Effect.succeed(`proof:${input.url}`), }), ); - return managedRelayClientLayer({ + return ManagedRelay.layer({ relayUrl, clientId: "t3-mobile", ...(accessTokenStore ? { accessTokenStore } : {}), @@ -90,7 +83,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus({ clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -124,13 +117,14 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const error = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", + _tag: "ManagedRelayUrlInvalidError", + relayUrl: "http://relay.example.test", message: "Relay URL must be a secure absolute HTTPS origin.", }); expect(requestCount).toBe(0); @@ -175,7 +169,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const statusInput = { clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -198,8 +192,8 @@ describe("ManagedRelayClient", () => { it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { let tokenExchangeCount = 0; - let persistedTokens: ReadonlyArray = []; - const accessTokenStore: ManagedRelayAccessTokenStore = { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.sync(() => persistedTokens), save: (entries) => Effect.sync(() => { @@ -252,7 +246,7 @@ describe("ManagedRelayClient", () => { return Effect.gen(function* () { yield* Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); @@ -260,7 +254,7 @@ describe("ManagedRelayClient", () => { expect(persistedTokens).toHaveLength(1); yield* Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); @@ -271,7 +265,7 @@ describe("ManagedRelayClient", () => { it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => { let tokenExchangeCount = 0; const statusTokens: Array = []; - let persistedTokens: ReadonlyArray = [ + let persistedTokens: ReadonlyArray = [ { accountId: "user-1", clientId: "t3-mobile", @@ -282,7 +276,7 @@ describe("ManagedRelayClient", () => { expiresAtMillis: Number.MAX_SAFE_INTEGER, }, ]; - const accessTokenStore: ManagedRelayAccessTokenStore = { + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.sync(() => persistedTokens), save: (entries) => Effect.sync(() => { @@ -344,7 +338,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const result = yield* relayClient.getEnvironmentStatus({ clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -363,8 +357,8 @@ describe("ManagedRelayClient", () => { }); it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { - let persistedTokens: ReadonlyArray = []; - const accessTokenStore: ManagedRelayAccessTokenStore = { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.succeed([]), save: (entries) => Effect.sync(() => { @@ -407,7 +401,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus({ clerkToken: "not-a-jwt", scopes: [RelayEnvironmentStatusScope], @@ -423,17 +417,19 @@ describe("ManagedRelayClient", () => { new Promise(() => undefined)) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const errorFiber = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + yield* TestClock.adjust(Duration.millis(ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS)); const error = yield* Fiber.join(errorFiber); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", + _tag: "ManagedRelayRequestTimeoutError", + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, message: "Relay environment listing timed out.", }); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); @@ -454,13 +450,13 @@ describe("ManagedRelayClient", () => { )) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const error = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", + _tag: "ManagedRelayRequestFailedError", traceId: "trace-managed-relay", }); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); @@ -499,7 +495,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); expect(devices).toMatchObject([ { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 97484fe7d26..08b720b46a3 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -5,7 +5,7 @@ import { type RelayClientDeviceRecord, RelayConnectEnvironmentEndpoint, type RelayDeviceRegistrationRequest, - type RelayDpopAccessTokenScope, + RelayDpopAccessTokenScope, RelayDpopTokenExchangeGrantType, type RelayEnvironmentConnectRequest, type RelayEnvironmentConnectResponse, @@ -33,58 +33,184 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { HttpClientError } from "effect/unstable/http"; -import type { HttpMethod } from "effect/unstable/http/HttpMethod"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpMethod from "effect/unstable/http/HttpMethod"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; export interface ManagedRelayDpopProofInput { - readonly method: HttpMethod; + readonly method: HttpMethod.HttpMethod; readonly url: string; readonly accessToken?: string; } -export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ - readonly cause: unknown; -}> {} +export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopKeyLoadError", + { + keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not load relay DPoP proof key."; + } +} + +export class ManagedRelayDpopProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`; + } +} -export class ManagedRelayRequestTimeoutError extends Data.TaggedError( +export const ManagedRelayDpopSignerError = Schema.Union([ + ManagedRelayDpopKeyLoadError, + ManagedRelayDpopProofCreationError, +]); +export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type; + +export const ManagedRelayRequestAction = Schema.Literals([ + "exchange relay DPoP access token", + "list relay-managed environments", + "list relay client devices", + "create relay environment link challenge", + "link relay environment", + "unlink relay environment", + "get relay environment status", + "connect relay environment", + "register relay mobile device", + "unregister relay mobile device", + "register relay live activity", +]); +export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; + +export const ManagedRelayRequestActivity = Schema.Literals([ + "Relay DPoP access token exchange", + "Relay environment listing", + "Relay client device listing", + "Relay environment link challenge", + "Relay environment linking", + "Relay environment unlinking", + "Relay environment status request", + "Relay environment connection", + "Relay mobile device registration", + "Relay mobile device unregistration", + "Relay Live Activity registration", +]); +export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; + +export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass()( "ManagedRelayRequestTimeoutError", -)<{ - readonly message: string; -}> {} + { + activity: ManagedRelayRequestActivity, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `${this.activity} timed out.`; + } +} + +export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass()( + "ManagedRelayUrlInvalidError", + { + relayUrl: Schema.String, + }, +) { + override get message(): string { + return "Relay URL must be a secure absolute HTTPS origin."; + } +} + +export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestFailedError", + { + action: ManagedRelayRequestAction, + cause: Schema.Defect(), + relayError: Schema.optional(RelayProtectedError), + traceId: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Could not ${this.action}.`; + } +} + +export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass()( + "ManagedRelayAccessTokenScopesUnexpectedError", + { + requestedScopes: Schema.Array(RelayDpopAccessTokenScope), + grantedScope: Schema.String, + }, +) { + override get message(): string { + return "Relay granted unexpected DPoP access token scopes."; + } +} + +export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not create relay token DPoP proof."; + } +} + +export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not create relay request DPoP proof."; + } +} + +export const ManagedRelayClientError = Schema.Union([ + ManagedRelayUrlInvalidError, + ManagedRelayRequestFailedError, + ManagedRelayRequestTimeoutError, + ManagedRelayAccessTokenScopesUnexpectedError, + ManagedRelayDpopKeyLoadError, + ManagedRelayTokenProofCreationError, + ManagedRelayRequestProofCreationError, +]); +export type ManagedRelayClientError = typeof ManagedRelayClientError.Type; type RelayHttpRequestError = | RelayProtectedErrorType | HttpClientError.HttpClientError - | Schema.SchemaError - | ManagedRelayRequestTimeoutError; - -export interface ManagedRelayDpopSignerShape { - readonly thumbprint: Effect.Effect; - readonly createProof: ( - input: ManagedRelayDpopProofInput, - ) => Effect.Effect; -} + | Schema.SchemaError; export class ManagedRelayDpopSigner extends Context.Service< ManagedRelayDpopSigner, - ManagedRelayDpopSignerShape + { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; + } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} -export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ - readonly message: string; - readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError; - readonly relayError?: RelayProtectedErrorType; - readonly traceId?: string; -}> {} - export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; export interface ManagedRelayAccessTokenCacheEntry { @@ -115,100 +241,96 @@ export interface ManagedRelayClientLayerOptions { readonly accessTokenStore?: ManagedRelayAccessTokenStore; } -export interface ManagedRelayClientShape { - readonly relayUrl: string; - readonly listEnvironments: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly listDevices: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly createEnvironmentLinkChallenge: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkChallengeRequest; - }) => Effect.Effect; - readonly linkEnvironment: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkRequest; - }) => Effect.Effect; - readonly unlinkEnvironment: (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly getEnvironmentStatus: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly connectEnvironment: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly deviceId?: string; - }) => Effect.Effect; - readonly registerDevice: (input: { - readonly clerkToken: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregisterDevice: (input: { - readonly clerkToken: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly registerLiveActivity: (input: { - readonly clerkToken: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly resetTokenCache: Effect.Effect; -} - export class ManagedRelayClient extends Context.Service< ManagedRelayClient, - ManagedRelayClientShape + { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; + } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} const isRelayProtectedError = Schema.is(RelayProtectedError); -function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError { - return new ManagedRelayClientError({ - message, - ...(cause === undefined ? {} : { cause }), - }); -} - -function relayLocalError( - message: string, - cause: ManagedRelayDpopSignerError, -): ManagedRelayClientError { - return new ManagedRelayClientError({ message, cause }); -} - -function relayRequestError(message: string) { +function relayRequestError(action: ManagedRelayRequestAction) { return (cause: RelayHttpRequestError): ManagedRelayClientError => - new ManagedRelayClientError({ - message, + new ManagedRelayRequestFailedError({ + action, cause, ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), }); } +function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) { + return { + method: error.method, + url: error.url, + cause: error, + }; +} + function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { return ( + error._tag === "ManagedRelayRequestFailedError" && error.relayError?._tag === "RelayAuthInvalidError" && error.relayError.reason === "invalid_bearer" ); } -function timeoutRelayRequest(message: string) { +function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { return ( - request: Effect.Effect, + effect: Effect.Effect, ): Effect.Effect => - request.pipe( + effect.pipe( Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), Effect.flatMap( Option.match({ onNone: () => Effect.fail( - relayClientError(message, new ManagedRelayRequestTimeoutError({ message })), + new ManagedRelayRequestTimeoutError({ + activity, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), ), onSome: Effect.succeed, }), @@ -258,10 +380,10 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { }; } -function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] { const unavailable = (spanName: string) => Effect.fn(spanName)(function* () { - return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin."); + return yield* new ManagedRelayUrlInvalidError({ relayUrl }); }); return ManagedRelayClient.of({ relayUrl, @@ -283,482 +405,475 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { }); } -export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { - return Layer.effect( - ManagedRelayClient, - Effect.gen(function* () { - const relayUrl = normalizeSecureRelayUrl(options.relayUrl); - if (relayUrl === null) { - return disabledManagedRelayClient(options.relayUrl); - } - const signer = yield* ManagedRelayDpopSigner; - const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); - const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; - const cachedTokens = yield* SynchronizedRef.make< - ReadonlyArray - >(initialTokens.filter((token) => token.clientId === options.clientId)); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); - - type DpopProofTarget = Pick; - const dpopProofTargets = { - exchangeAccessToken: (): DpopProofTarget => ({ - method: RelayExchangeDpopAccessTokenEndpoint.method, - url: urlBuilder.token.exchangeDpopAccessToken(), - }), - getEnvironmentStatus: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayGetEnvironmentStatusEndpoint.method, - url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), - }), - connectEnvironment: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayConnectEnvironmentEndpoint.method, - url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), - }), - registerDevice: (): DpopProofTarget => ({ - method: RelayRegisterDeviceEndpoint.method, - url: urlBuilder.mobile.registerDevice(), - }), - unregisterDevice: (deviceId: string): DpopProofTarget => ({ - method: RelayUnregisterDeviceEndpoint.method, - url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), - }), - registerLiveActivity: (): DpopProofTarget => ({ - method: RelayRegisterLiveActivityEndpoint.method, - url: urlBuilder.mobile.registerLiveActivity(), - }), - }; +export const make = Effect.fn("ManagedRelayClient.make")(function* ( + options: ManagedRelayClientLayerOptions, +) { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; + const cachedTokens = yield* SynchronizedRef.make< + ReadonlyArray + >(initialTokens.filter((token) => token.clientId === options.clientId)); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; - const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - }); - const proof = yield* signer - .createProof(dpopProofTargets.exchangeAccessToken()) - .pipe( - Effect.mapError((cause) => - relayLocalError("Could not create relay token DPoP proof.", cause), - ), - ); - const response = yield* client.token - .exchangeDpopAccessToken({ - headers: { dpop: proof }, - payload: { - grant_type: RelayDpopTokenExchangeGrantType, - subject_token: input.clerkToken, - subject_token_type: RelayJwtSubjectTokenType, - requested_token_type: RelayAccessTokenType, - resource: relayUrl, - scope: encodeOAuthScope(input.scopes), - client_id: options.clientId, - }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")), - timeoutRelayRequest("Relay DPoP access token exchange timed out."), - ); - if (!oauthScopeSetEquals(response.scope, input.scopes)) { - return yield* relayClientError("Relay granted unexpected DPoP access token scopes."); - } - return response; - }, - ); + const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError( + (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError(relayRequestError("exchange relay DPoP access token")), + timeoutRelayRequest("Relay DPoP access token exchange"), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* new ManagedRelayAccessTokenScopesUnexpectedError({ + requestedScopes: input.scopes, + grantedScope: response.scope, + }); + } + return response; + }, + ); - const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly thumbprint: string; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - }); - const nowMillis = yield* Clock.currentTimeMillis; - const accountId = relayAccountId(input.clerkToken); - if (Option.isNone(accountId)) { - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "bypass", - "relay.token_cache.bypass_reason": "invalid_subject_token", - }); - const response = yield* exchangeAccessToken(input); - return { - accountId: "", + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const nowMillis = yield* Clock.currentTimeMillis; + const accountId = relayAccountId(input.clerkToken); + if (Option.isNone(accountId)) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "bypass", + "relay.token_cache.bypass_reason": "invalid_subject_token", + }); + const response = yield* exchangeAccessToken(input); + return { + accountId: "", + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + } satisfies ManagedRelayAccessTokenCacheEntry; + } + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => + Effect.gen(function* () { + const activeTokens = tokens.filter((token) => token.expiresAtMillis > nowMillis + 5_000); + const cached = activeTokens.find((token) => + tokenMatches(token, { + accountId: accountId.value, clientId: options.clientId, relayUrl, thumbprint: input.thumbprint, scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - } satisfies ManagedRelayAccessTokenCacheEntry; + nowMillis, + }), + ); + if (cached) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "hit", + }); + return [cached, activeTokens] as const; } - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => - Effect.gen(function* () { - const activeTokens = tokens.filter( - (token) => token.expiresAtMillis > nowMillis + 5_000, - ); - const cached = activeTokens.find((token) => - tokenMatches(token, { - accountId: accountId.value, - clientId: options.clientId, - relayUrl, - thumbprint: input.thumbprint, - scopes: input.scopes, - nowMillis, - }), - ); - if (cached) { - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "hit", - }); - return [cached, activeTokens] as const; - } - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "miss", - }); - const response = yield* exchangeAccessToken(input); - const next: ManagedRelayAccessTokenCacheEntry = { - accountId: accountId.value, - clientId: options.clientId, - relayUrl, - thumbprint: input.thumbprint, - scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - }; - const nextTokens = [...activeTokens, next]; - if (options.accessTokenStore) { - yield* options.accessTokenStore.save(nextTokens); + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "miss", + }); + const response = yield* exchangeAccessToken(input); + const next: ManagedRelayAccessTokenCacheEntry = { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + const nextTokens = [...activeTokens, next]; + if (options.accessTokenStore) { + yield* options.accessTokenStore.save(nextTokens); + } + return [next, nextTokens] as const; + }), + ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); + }, + ); + + const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + "http.request.method": input.target.method, + "url.full": input.target.url, + }); + const thumbprint = yield* signer.thumbprint; + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( + function* (accessToken: string) { + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { + const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); + if (nextTokens.length === tokens.length) { + return Effect.succeed([false, tokens] as const); + } + return ( + options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void + ).pipe(Effect.as([true, nextTokens] as const)); + }); + }, + ); + + const runDpopRequest = ( + input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ): Effect.Effect => { + const attempt = (refreshRejectedToken: boolean): Effect.Effect => + authorize(input).pipe( + Effect.flatMap((authorization) => + request(authorization).pipe( + Effect.catch((error) => { + if (!isRejectedDpopAccessToken(error)) { + return Effect.fail(error); } - return [next, nextTokens] as const; + return invalidateAccessToken(authorization.accessToken).pipe( + Effect.tap((invalidated) => + Effect.annotateCurrentSpan({ + "relay.token_cache.invalidated": invalidated, + "relay.token_cache.invalidation_reason": "invalid_bearer", + "relay.token_cache.retry_after_invalidation": refreshRejectedToken, + }), + ), + Effect.tap((invalidated) => + invalidated && refreshRejectedToken + ? Effect.logWarning( + "Relay rejected a cached DPoP access token; refreshing it once.", + ) + : Effect.void, + ), + Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), + ); }), - ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); - }, + ), + ), ); + return attempt(true); + }; - const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - "http.request.method": input.target.method, - "url.full": input.target.url, - }); - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError((cause) => - relayLocalError("Could not load relay DPoP proof key.", cause), - ), - ); - const token = yield* obtainAccessToken({ - clerkToken: input.clerkToken, - scopes: input.scopes, - thumbprint, - }); - const proof = yield* signer - .createProof({ - ...input.target, - accessToken: token.accessToken, + const mobileRegistrationRequest = ( + input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ) => + runDpopRequest( + { + ...input, + scopes: [RelayMobileRegistrationScope], + }, + request, + ); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("list relay-managed environments")), + timeoutRelayRequest("Relay environment listing"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), + withRelayClientTracing, + ), + listDevices: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), }) .pipe( - Effect.mapError((cause) => - relayLocalError("Could not create relay request DPoP proof.", cause), - ), + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("list relay client devices")), + timeoutRelayRequest("Relay client device listing"), ); - return { accessToken: token.accessToken, proof, thumbprint }; - }); - - const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( - function* (accessToken: string) { - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { - const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); - if (nextTokens.length === tokens.length) { - return Effect.succeed([false, tokens] as const); - } - return ( - options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void - ).pipe(Effect.as([true, nextTokens] as const)); - }); - }, - ); - - const runDpopRequest = ( - input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }, - request: ( - authorization: ManagedRelayAuthorization, - ) => Effect.Effect, - ): Effect.Effect => { - const attempt = ( - refreshRejectedToken: boolean, - ): Effect.Effect => - authorize(input).pipe( - Effect.flatMap((authorization) => - request(authorization).pipe( - Effect.catch((error) => { - if (!isRejectedDpopAccessToken(error)) { - return Effect.fail(error); - } - return invalidateAccessToken(authorization.accessToken).pipe( - Effect.tap((invalidated) => - Effect.annotateCurrentSpan({ - "relay.token_cache.invalidated": invalidated, - "relay.token_cache.invalidation_reason": "invalid_bearer", - "relay.token_cache.retry_after_invalidation": refreshRejectedToken, - }), - ), - Effect.tap((invalidated) => - invalidated && refreshRejectedToken - ? Effect.logWarning( - "Relay rejected a cached DPoP access token; refreshing it once.", - ) - : Effect.void, - ), - Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), - ); - }), - ), - ), + }, + Effect.withSpan("clientRuntime.managedRelay.listDevices"), + withRelayClientTracing, + ), + createEnvironmentLinkChallenge: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("create relay environment link challenge")), + timeoutRelayRequest("Relay environment link challenge"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), + withRelayClientTracing, + ), + linkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("link relay environment")), + timeoutRelayRequest("Relay environment linking"), ); - return attempt(true); - }; - - const mobileRegistrationRequest = ( - input: { - readonly clerkToken: string; - readonly target: DpopProofTarget; - }, - request: ( - authorization: ManagedRelayAuthorization, - ) => Effect.Effect, - ) => - runDpopRequest( + }, + Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), + withRelayClientTracing, + ), + unlinkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("unlink relay environment")), + timeoutRelayRequest("Relay environment unlinking"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), + withRelayClientTracing, + ), + getEnvironmentStatus: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( { - ...input, - scopes: [RelayMobileRegistrationScope], + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), }, - request, - ); - - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + (authorization) => + client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) .pipe( - Effect.map((response) => response.environments), - Effect.mapError(relayRequestError("Could not list relay-managed environments.")), - timeoutRelayRequest("Relay environment listing timed out."), - ); + Effect.mapError(relayRequestError("get relay environment status")), + timeoutRelayRequest("Relay environment status request"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), + withRelayClientTracing, + ), + connectEnvironment: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), }, - Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), - withRelayClientTracing, - ), - listDevices: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listDevices({ - headers: bearerHeaders(input.clerkToken), + (authorization) => { + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, }) .pipe( - Effect.map((response) => response.devices), - Effect.mapError(relayRequestError("Could not list relay client devices.")), - timeoutRelayRequest("Relay client device listing timed out."), + Effect.mapError(relayRequestError("connect relay environment")), + timeoutRelayRequest("Relay environment connection"), ); }, - Effect.withSpan("clientRuntime.managedRelay.listDevices"), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: Effect.fnUntraced( - function* (input) { - return yield* client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), + withRelayClientTracing, + ), + registerDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }, + (authorization) => + client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), payload: input.payload, }) .pipe( - Effect.mapError( - relayRequestError("Could not create relay environment link challenge."), - ), - timeoutRelayRequest("Relay environment link challenge timed out."), - ); + Effect.mapError(relayRequestError("register relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerDevice"), + withRelayClientTracing, + ), + unregisterDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), }, - Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), - withRelayClientTracing, - ), - linkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, + (authorization) => + client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, }) .pipe( - Effect.mapError(relayRequestError("Could not link relay environment.")), - timeoutRelayRequest("Relay environment linking timed out."), - ); + Effect.mapError(relayRequestError("unregister relay mobile device")), + timeoutRelayRequest("Relay mobile device unregistration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), + withRelayClientTracing, + ), + registerLiveActivity: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), }, - Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), - withRelayClientTracing, - ), - unlinkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, + (authorization) => + client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("Could not unlink relay environment.")), - timeoutRelayRequest("Relay environment unlinking timed out."), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), - withRelayClientTracing, - ), - getEnvironmentStatus: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }, - (authorization) => - client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not get relay environment status.")), - timeoutRelayRequest("Relay environment status request timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), - withRelayClientTracing, - ), - connectEnvironment: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }, - (authorization) => { - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not connect relay environment.")), - timeoutRelayRequest("Relay environment connection timed out."), - ); - }, - ); - }, - Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), - withRelayClientTracing, - ), - registerDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }, - (authorization) => - client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not register relay mobile device.")), - timeoutRelayRequest("Relay mobile device registration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerDevice"), - withRelayClientTracing, - ), - unregisterDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }, - (authorization) => - client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not unregister relay mobile device.")), - timeoutRelayRequest("Relay mobile device unregistration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), - withRelayClientTracing, - ), - registerLiveActivity: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }, - (authorization) => - client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not register relay live activity.")), - timeoutRelayRequest("Relay Live Activity registration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), - withRelayClientTracing, - ), - resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( - Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), - Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), - withRelayClientTracing, - ), - }); - }), - ); -} + Effect.mapError(relayRequestError("register relay live activity")), + timeoutRelayRequest("Relay Live Activity registration"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), + withRelayClientTracing, + ), + resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( + Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + withRelayClientTracing, + ), + }); +}); + +export const layer = (options: ManagedRelayClientLayerOptions) => + Layer.effect(ManagedRelayClient, make(options)); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 43b020d0840..49400d32aef 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -12,11 +12,7 @@ import * as Stream from "effect/Stream"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, vi } from "vite-plus/test"; -import { - ManagedRelayClient, - ManagedRelayClientError, - type ManagedRelayClientShape, -} from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; import { createManagedRelayQueryManager, createManagedRelaySession, @@ -66,10 +62,10 @@ function resetRegistry() { } function createManager( - overrides?: Partial, + overrides?: Partial, onQueryEvent?: (event: ManagedRelayQueryEvent) => void, ) { - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.succeed([environment]), listDevices: () => Effect.succeed([device]), @@ -90,7 +86,7 @@ function createManager( resetTokenCache: Effect.void, ...overrides, }); - const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); + const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000, ...(onQueryEvent ? { onQueryEvent } : {}), @@ -363,8 +359,9 @@ describe("createManagedRelayQueryManager", () => { const manager = createManager({ getEnvironmentStatus: () => Effect.fail( - new ManagedRelayClientError({ - message: "Could not get relay environment status.", + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "get relay environment status", + cause: new Error("Relay request failed."), traceId: "trace-status", }), ), diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 8a26d2f698f..ec6a0710dd1 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -16,7 +16,7 @@ import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { findErrorTraceId } from "../errors/errorTrace.ts"; -import { ManagedRelayClient } from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; @@ -308,7 +308,7 @@ export function readManagedRelaySnapshotState( } export function createManagedRelayQueryManager( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options?: { readonly staleTimeMs?: number; readonly idleTtlMs?: number; @@ -351,7 +351,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; return yield* observe( { ...base, stage: "relay-request" }, relay.listEnvironments({ clerkToken }), @@ -374,7 +374,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; return yield* observe( { ...base, stage: "relay-request" }, relay.listDevices({ clerkToken }), @@ -402,7 +402,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; const status = yield* observe( { ...base, stage: "relay-request" }, relay.getEnvironmentStatus({ diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index 927671e176f..bdf217d0880 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -4,34 +4,33 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { - EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, - RelayEnvironmentDiscovery, -} from "../relay/discovery.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import { createRuntimeCommand } from "./runtime.ts"; export function createRelayEnvironmentDiscoveryAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const stateAtom = runtime.atom( Stream.unwrap( - RelayEnvironmentDiscovery.pipe( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), ), ), - { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, + { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, ); const stateValueAtom = Atom.make((get) => Option.getOrElse( AsyncResult.value(get(stateAtom)), - () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); const refresh = createRuntimeCommand(runtime, { label: "relay-environment-discovery:refresh", concurrency: { mode: "singleFlight", key: () => "refresh" }, execute: (_input: void) => - RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)), + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( + Effect.flatMap((discovery) => discovery.refresh), + ), }); return { From 7eda6ba5fce8290b3a13d1ab730827fc36a34e53 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:36:41 -0700 Subject: [PATCH 061/142] [codex] Migrate desktop shell and SSH Effect services (#3194) Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.ts | 45 ++-- .../src/ssh/DesktopSshEnvironment.test.ts | 15 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 102 +++++--- .../src/ssh/DesktopSshPasswordPrompts.ts | 238 +++++++++++------- 4 files changed, 240 insertions(+), 160 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 13ac35b6297..a48e896b7f5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,8 @@ 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 ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -19,13 +20,11 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } -export interface DesktopShellEnvironmentShape { - readonly installIntoProcess: Effect.Effect; -} - export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, - DesktopShellEnvironmentShape + { + readonly installIntoProcess: Effect.Effect; + } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [ @@ -336,20 +335,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.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)); From 9a1c4875a37ea7eeb77db189e2e45db9404d7a47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:37:47 -0700 Subject: [PATCH 062/142] [codex] Remove redundant Effect type annotations (#3229) Co-authored-by: codex --- apps/desktop/src/app/DesktopObservability.ts | 6 +- .../backend/DesktopBackendConfiguration.ts | 6 +- .../src/shell/DesktopShellEnvironment.ts | 6 +- .../src/window/DesktopApplicationMenu.ts | 12 +--- apps/mobile/src/connection/runtime.ts | 18 ++--- apps/mobile/src/lib/runtime.ts | 13 ++-- apps/mobile/src/state/relay.ts | 2 +- apps/server/src/mcp/McpSessionRegistry.ts | 6 +- .../ProviderInstanceRegistryHydration.ts | 6 +- apps/web/src/cloud/dpop.ts | 71 +++++++++---------- apps/web/src/connection/runtime.ts | 18 ++--- apps/web/src/lib/runtime.ts | 13 ++-- packages/client-runtime/src/rpc/client.ts | 9 +-- packages/tailscale/src/tailscale.ts | 6 +- 14 files changed, 69 insertions(+), 123 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index eae352aa376..21dd27ba28d 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -50,11 +50,7 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { }; } -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); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 18316743fc6..ec72faf910b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -54,11 +54,7 @@ const { logWarning: logBackendConfigurationWarning } = DesktopObservability.make "desktop-backend-configuration", ); -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 diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index a48e896b7f5..62a3b6efc91 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -211,11 +211,7 @@ const readLoginShellEnvironment = ( timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); -const readLaunchctlPath: Effect.Effect< - Option.Option, - never, - ChildProcessSpawner.ChildProcessSpawner -> = runCommandOutput({ +const readLaunchctlPath = runCommandOutput({ command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 04b9c833e44..733c1f5494d 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -37,11 +37,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"); @@ -65,11 +61,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; diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index f35b938dc6c..3698a0a5fc7 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -5,24 +5,20 @@ import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; import { connectionPlatformLayer } from "./platform"; -const providedConnectionPlatformLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); type ConnectionLayerSource = | typeof Connection.layer | typeof runtimeContextLayer - | typeof providedConnectionPlatformLayer; + | typeof connectionPlatformLayer; -export const connectionLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = Connection.layer.pipe( +const connectionLayer = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); export const connectionAtomRuntime: Atom.AtomRuntime< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index f760bef3459..51a4885562c 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -22,10 +22,7 @@ type RuntimeLayerSource = | typeof httpClientLayer | typeof tracingLayer; -export const runtimeLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = Layer.merge( +const runtimeLayer = Layer.merge( managedRelayClientLayer(configuredRelayUrl()), Socket.layerWebSocketConstructorGlobal, ).pipe( @@ -35,11 +32,11 @@ export const runtimeLayer: Layer.Layer< ); export const runtime: ManagedRuntime.ManagedRuntime< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = ManagedRuntime.make(runtimeLayer); export const runtimeContextLayer: Layer.Layer< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts index 3cbac7a1875..f078572736b 100644 --- a/apps/mobile/src/state/relay.ts +++ b/apps/mobile/src/state/relay.ts @@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st import { connectionAtomRuntime } from "../connection/runtime"; -export const relayEnvironmentDiscovery: ReturnType = +export const relayEnvironmentDiscovery = createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index de9dc958415..67c4f2f0ff0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -191,11 +191,7 @@ const make = Effect.acquireRelease( }), ); -export const layer: Layer.Layer< - McpSessionRegistry, - never, - Crypto.Crypto | ServerEnvironment.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/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/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/connection/runtime.ts b/apps/web/src/connection/runtime.ts index f35b938dc6c..3698a0a5fc7 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -5,24 +5,20 @@ import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; import { connectionPlatformLayer } from "./platform"; -const providedConnectionPlatformLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); type ConnectionLayerSource = | typeof Connection.layer | typeof runtimeContextLayer - | typeof providedConnectionPlatformLayer; + | typeof connectionPlatformLayer; -export const connectionLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = Connection.layer.pipe( +const connectionLayer = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); export const connectionAtomRuntime: Atom.AtomRuntime< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = Atom.runtime(connectionLayer); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index a4d87a7ae01..3836d2a3916 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -54,10 +54,7 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const runtimeLayer: Layer.Layer< - Layer.Success, - Layer.Error -> = Layer.mergeAll( +const runtimeLayer = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, @@ -68,11 +65,11 @@ export const runtimeLayer: Layer.Layer< ); export const runtime: ManagedRuntime.ManagedRuntime< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = ManagedRuntime.make(runtimeLayer); export const runtimeContextLayer: Layer.Layer< - Layer.Success, - Layer.Error + Layer.Success, + Layer.Error > = Layer.effectContext(runtime.contextEffect); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 882d8f51b53..92892431e45 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,4 +1,4 @@ -import { ORCHESTRATION_WS_METHODS, type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import type * as Duration from "effect/Duration"; @@ -9,7 +9,6 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { RpcClientError } from "effect/unstable/rpc"; -import type { ConnectionAttemptError } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; @@ -237,11 +236,7 @@ export function subscribe( ); } -export const config: Effect.Effect< - ServerConfig, - EnvironmentRpcUnavailableError | ConnectionAttemptError, - EnvironmentSupervisor -> = Effect.gen(function* () { +export const config = Effect.gen(function* () { const session = yield* currentSession(); return yield* session.initialConfig; }).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index c35b2ae03a1..f468dec7294 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -135,11 +135,7 @@ export const parseTailscaleStatus = ( }), ); -export const readTailscaleStatus: Effect.Effect< - TailscaleStatus, - TailscaleCommandError | TailscaleStatusParseError, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; From f58b6da2308bda9e1c977dba5280747d0e4e70e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:39:04 -0700 Subject: [PATCH 063/142] [codex] Preserve workspace RPC error messages (#3222) Co-authored-by: codex --- .../src/project/ProjectSetupScriptRunner.ts | 2 +- apps/server/src/server.test.ts | 69 ++++++++++++--- apps/server/src/workspace/WorkspaceEntries.ts | 62 +++----------- apps/server/src/ws.ts | 84 +++++++++++++++++-- 4 files changed, 147 insertions(+), 70 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 57540088128..dc97da51f24 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return `Project setup script project was not found for thread '${this.threadId}'.`; + return "Project was not found for setup script execution."; } } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fd69c610df4..19988b20213 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4430,24 +4430,68 @@ 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 workspace rpc failure messages", () => 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")); + yield* buildAppUnderTest(); + const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); + const missingBrowseParent = path.join(workspaceDir, "missing-browse"); 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: "needle", + 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"); - assert.equal(result.failure.message, "Failed to search workspace entries."); + assertTrue(results.search._tag === "Failure"); + assert.equal( + results.search.failure.message, + `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + ); + assertTrue(results.list._tag === "Failure"); + assert.equal( + results.list.failure.message, + `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + ); + assertTrue(results.read._tag === "Failure"); + assert.equal( + results.read.failure.message, + "Failed to read workspace file: Workspace file path resolves outside the project root.", + ); + assertTrue(results.browse._tag === "Failure"); + assert.equal( + results.browse.failure.message, + `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6102,7 +6146,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { threadId: input.threadId, worktreePath: input.worktreePath, operation: "openTerminal", - cause: new Error("pty unavailable"), + cause: { message: "pty unavailable" }, }), ), ); @@ -6177,8 +6221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: - "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.", + detail: "pty unavailable", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index aafd6ffd75a..398b3d951b3 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -23,23 +23,6 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", - { - cwd: Schema.String, - operation: Schema.Literals([ - "workspaceEntries.normalizeWorkspaceRoot", - "workspaceEntries.search", - "workspaceEntries.list", - ]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`; - } -} - export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()( "WorkspaceEntriesWindowsPathUnsupportedError", { @@ -87,6 +70,16 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ ]); 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, { @@ -146,16 +139,7 @@ export 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", - cause, - }), - ), - ); + return yield* workspacePaths.normalizeWorkspaceRoot(cwd); }); const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( @@ -243,17 +227,7 @@ export 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", - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); @@ -263,17 +237,7 @@ export 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", - cause, - }), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 935dd47cc85..e76b3f63d7a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -112,6 +112,77 @@ const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOu const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function unexpectedCompatibilityError(error: never): never { + throw new Error(`Unhandled compatibility error: ${String(error)}`); +} + +/** Preserve pre-structured-error display behavior at the RPC boundary. */ +function legacyPlatformFailureDescription(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** 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 workspaceEntriesCompatibilityDetail( + error: WorkspaceEntries.WorkspaceEntriesError, +): string { + switch (error._tag) { + case "WorkspaceRootNotExistsError": + return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceRootCreateFailedError": + return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceRootNotDirectoryError": + return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; + case "WorkspaceSearchIndexCreateFailed": + return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; + case "WorkspaceSearchIndexScanTimedOut": + return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; + case "WorkspaceSearchIndexSearchFailed": + return `Workspace search failed for '${error.cwd}': ${error.reason}`; + default: + return unexpectedCompatibilityError(error); + } +} + +function workspaceBrowseCompatibilityDetail( + error: WorkspaceEntries.WorkspaceEntriesBrowseError, +): string { + switch (error._tag) { + case "WorkspaceEntriesWindowsPathUnsupportedError": + return "Windows-style paths are only supported on Windows."; + case "WorkspaceEntriesCurrentProjectRequiredError": + return "Relative filesystem browse paths require a current project."; + case "WorkspaceEntriesReadDirectoryError": + return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; + 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, { @@ -561,12 +632,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.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", @@ -1190,7 +1260,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: "Failed to search workspace entries.", + message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, cause, }), ), @@ -1204,7 +1274,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: "Failed to list workspace entries.", + message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, cause, }), ), @@ -1218,7 +1288,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError((cause) => { const message = isWorkspacePathOutsideRootError(cause) ? "Workspace file path must stay within the project root." - : "Failed to read workspace file."; + : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; return new ProjectReadFileError({ message, cause }); }), ), @@ -1251,7 +1321,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: "Failed to browse the filesystem.", + message: workspaceBrowseCompatibilityDetail(cause), cause, }), ), From 30acfa9c51372588d698d25cbafae1bc147b2c78 Mon Sep 17 00:00:00 2001 From: ss <69873514+sandersonstabo@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:39:54 +0200 Subject: [PATCH 064/142] [fix/feat:ui] Fix clipped chatbar provider badge (#3224) --- apps/web/src/components/chat/ProviderModelPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index ebc47966702..e3463631733 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -158,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> } > - + {activeEntry ? ( Date: Sat, 20 Jun 2026 09:40:57 +0200 Subject: [PATCH 065/142] [fix/feat:ui] Use shared button for model favorites (#3223) --- apps/web/src/components/chat/ModelListRow.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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"} > - + } /> From db27502aff1add34eb2af4ff12c606e99bdc3610 Mon Sep 17 00:00:00 2001 From: ss <69873514+sandersonstabo@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:52:38 +0200 Subject: [PATCH 066/142] [fix/feat:ui] Capitalize Work Log heading (#3228) --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 2 +- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3207876f706..c2130381af6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -229,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 () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b0d83be7b10..88cbecb9bec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -736,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; From 2fa37ec183e0b99ca2ea77efbcb615f11019da75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:06:37 -0700 Subject: [PATCH 067/142] [codex] Enforce canonical Node namespace imports (#3238) Co-authored-by: codex --- .../effect-service-conventions.md | 2 +- .../scripts/build-preview-annotation-css.mjs | 32 +-- apps/desktop/scripts/dev-electron.mjs | 22 +- apps/desktop/scripts/electron-launcher.mjs | 162 ++++++++------- .../scripts/ensure-electron-runtime.mjs | 66 +++--- apps/desktop/scripts/smoke-test.mjs | 14 +- apps/desktop/scripts/start-electron.mjs | 4 +- apps/desktop/scripts/wait-for-resources.mjs | 16 +- .../src/backend/DesktopNetworkInterfaces.ts | 4 +- apps/desktop/src/ipc/methods/preview.ts | 4 +- .../src/preview/PlaywrightInjectedRuntime.ts | 18 +- .../scripts/sync-pierre-file-icons.mjs | 39 ++-- .../OrchestrationEngineHarness.integration.ts | 4 +- .../orchestrationEngine.integration.test.ts | 21 +- apps/server/scripts/acp-mock-agent.ts | 6 +- .../cursor-acp-model-mismatch-probe.ts | 48 ++--- apps/server/src/attachmentPaths.ts | 2 +- apps/server/src/attachmentStore.test.ts | 22 +- apps/server/src/attachmentStore.ts | 8 +- apps/server/src/auth/utils.ts | 6 +- apps/server/src/bin.test.ts | 72 ++++--- apps/server/src/bootstrap.test.ts | 26 +-- apps/server/src/bootstrap.ts | 26 +-- .../src/checkpointing/CheckpointStore.test.ts | 8 +- apps/server/src/cloud/CliTokenManager.ts | 4 +- apps/server/src/cloud/http.ts | 4 +- .../src/environment/ServerEnvironment.test.ts | 4 +- apps/server/src/git/GitManager.test.ts | 120 ++++++----- apps/server/src/git/Utils.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 34 +-- .../Layers/ProviderCommandReactor.test.ts | 13 +- .../Layers/ProviderRuntimeIngestion.test.ts | 12 +- apps/server/src/pathExpansion.test.ts | 10 +- apps/server/src/pathExpansion.ts | 8 +- .../src/persistence/NodeSqliteClient.ts | 18 +- apps/server/src/preview/PortScanner.test.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 +- .../src/provider/Layers/CodexAdapter.test.ts | 194 +++++++++--------- .../Layers/CodexSessionRuntime.test.ts | 36 ++-- .../src/provider/Layers/CursorAdapter.test.ts | 112 +++++----- .../src/provider/Layers/CursorProvider.ts | 4 +- .../provider/Layers/EventNdjsonLogger.test.ts | 59 +++--- .../src/provider/Layers/EventNdjsonLogger.ts | 8 +- .../src/provider/Layers/GrokAdapter.test.ts | 34 +-- .../provider/Layers/OpenCodeAdapter.test.ts | 96 ++++----- .../provider/Layers/OpenCodeProvider.test.ts | 41 ++-- .../provider/Layers/ProviderService.test.ts | 36 ++-- .../Layers/ProviderSessionDirectory.test.ts | 12 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 20 +- apps/server/src/provider/opencodeRuntime.ts | 4 +- .../src/provider/providerMaintenance.test.ts | 88 ++++---- .../src/relay/AgentAwarenessRelay.test.ts | 4 +- apps/server/src/server.test.ts | 44 ++-- apps/server/src/terminal/NodePtyAdapter.ts | 4 +- .../CursorTextGeneration.test.ts | 46 +++-- .../textGeneration/GrokTextGeneration.test.ts | 34 +-- apps/server/src/vcs/GitVcsDriver.ts | 7 +- .../src/workspace/WorkspaceEntries.test.ts | 12 +- apps/server/src/workspace/WorkspaceEntries.ts | 10 +- .../src/workspace/WorkspaceFileSystem.ts | 8 +- apps/server/src/workspace/WorkspacePaths.ts | 6 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/namespace-node-imports.test.ts | 63 ++++++ .../rules/namespace-node-imports.ts | 76 +++++++ packages/shared/src/logging.ts | 30 +-- packages/shared/src/shell.ts | 16 +- packages/ssh/src/command.ts | 7 +- scripts/build-desktop-artifact.ts | 4 +- scripts/lib/public-config.test.ts | 18 +- scripts/release-smoke.ts | 126 ++++++------ vite.config.ts | 5 +- 71 files changed, 1205 insertions(+), 952 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/namespace-node-imports.test.ts create mode 100644 oxlint-plugin-t3code/rules/namespace-node-imports.ts diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 1bbd8192cb5..d474c41d2fe 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -27,7 +27,7 @@ Review changed TypeScript and directly affected call sites for the conventions b - 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 `electron`, and for modules used only for a pure helper, error, schema, config value, or standalone type. Do not request `import type * as Contracts`. +- 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. 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 73d778fb48b..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)"; @@ -32,22 +21,35 @@ export const APP_BUNDLE_ID = isDevelopment : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; const LAUNCHER_VERSION = 12; -const defaultIconPath = join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = NodePath.join( + repoRoot, + "assets", + "dev", + "blueprint-macos-1024.png", +); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. const hostPlatform = NodeOS.platform(); function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const replaceResult = NodeChildProcess.spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { - encoding: "utf8", - }); + const insertResult = NodeChildProcess.spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); if (insertResult.status === 0) { return; } @@ -58,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; } @@ -77,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; } @@ -91,7 +101,7 @@ function shellSingleQuote(value) { } function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs"); + 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], @@ -101,7 +111,7 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); - writeFileSync( + NodeFS.writeFileSync( targetBinaryPath, [ "#!/bin/sh", @@ -110,7 +120,7 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { "", ].join("\n"), ); - chmodSync(targetBinaryPath, 0o755); + NodeFS.chmodSync(targetBinaryPath, 0o755); } function registerMacLauncherBundle(appBundlePath) { @@ -140,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]) { @@ -164,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; @@ -174,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`), ]); } @@ -187,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); @@ -204,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) { @@ -218,7 +231,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { ]; for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { - const infoPlistPath = join( + const infoPlistPath = NodePath.join( appBundlePath, "Contents", "Frameworks", @@ -226,7 +239,7 @@ function patchHelperBundleInfoPlists(appBundlePath) { "Contents", "Info.plist", ); - if (!existsSync(infoPlistPath)) { + if (!NodeFS.existsSync(infoPlistPath)) { continue; } @@ -242,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) ) { @@ -277,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; @@ -299,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; @@ -322,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") { @@ -345,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/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index ad8c9eb8b14..79b6b824c8a 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -1,4 +1,4 @@ -import { networkInterfaces } from "node:os"; +import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,7 +27,7 @@ export class DesktopNetworkInterfaces extends Context.Service< export const make = (): DesktopNetworkInterfaces["Service"] => DesktopNetworkInterfaces.of({ - read: Effect.sync(() => networkInterfaces()), + read: Effect.sync(() => NodeOS.networkInterfaces()), }); export const layer = Layer.succeed(DesktopNetworkInterfaces, make()); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 99bede9045d..1994c270024 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -22,7 +22,7 @@ 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"; @@ -196,7 +196,7 @@ export const getPreviewConfig = DesktopIpc.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, }; }), }); 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/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/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 292b267e124..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 { @@ -82,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", 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/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/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/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 d71bc83f94e..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 { createServer } from "node:http"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as NodeHttp from "node:http"; +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"; @@ -124,7 +124,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ), Layer.provideMerge(makeProjectPersistenceLayer(config)), Layer.provideMerge( - NodeHttpServer.layer(createServer, { + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0, }), @@ -197,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"]), ); @@ -220,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]), ); @@ -234,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 baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"), + ); const { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); - mkdirSync(secretsDir, { recursive: true }); - writeFileSync( - join(secretsDir, "cloud-cli-oauth-token.bin"), + 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", @@ -267,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]), ); @@ -278,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 baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-logout-test-"), + ); const { secretsDir } = yield* ServerConfig.deriveServerPaths(baseDir, undefined); - const tokenPath = join(secretsDir, "cloud-cli-oauth-token.bin"); - mkdirSync(secretsDir, { recursive: true }); - writeFileSync(tokenPath, "invalid persisted token"); + 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"]), @@ -325,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"]), @@ -400,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", @@ -444,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* () { @@ -472,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..84cf85c3213 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NFS from "node:fs"; -import * as path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; @@ -53,8 +53,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 +78,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 { @@ -96,8 +96,8 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { 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); @@ -108,13 +108,13 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { 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 +125,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..1114ad8af90 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,8 +1,8 @@ // @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"; @@ -33,7 +33,7 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; return yield* Effect.callback, BootstrapError>((resume) => { - const input = readline.createInterface({ + const input = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity, }); @@ -96,7 +96,7 @@ 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.", @@ -113,7 +113,7 @@ const isFdReady = (fd: number) => const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.try({ + return yield* Effect.try({ try: () => { const fdPath = resolveFdPath(fd, platform); if (fdPath === undefined) { @@ -122,8 +122,8 @@ const makeBootstrapInputStream = (fd: number) => 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 +131,7 @@ const makeBootstrapInputStream = (fd: number) => } catch (error) { if (isBootstrapFdPathDuplicationError(error)) { if (streamFd !== undefined) { - NFS.closeSync(streamFd); + NodeFS.closeSync(streamFd); } return makeDirectBootstrapStream(fd); } @@ -146,15 +146,15 @@ const makeBootstrapInputStream = (fd: number) => }); }); -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/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index d796bdfc4c1..5a60012108b 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -1,5 +1,5 @@ // @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"; @@ -80,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"]); }); @@ -107,7 +107,7 @@ it.layer(TestLayer)("CheckpointStore.layer", (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, @@ -135,7 +135,7 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { 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/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index f2ad5e621ec..00709370b26 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -1,5 +1,5 @@ // @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"; @@ -206,7 +206,7 @@ export 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, diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 64c87f3487c..71be9f376d8 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,4 +1,4 @@ -import { createPublicKey } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { AuthRelayReadScope, AuthRelayWriteScope, @@ -152,7 +152,7 @@ function validateCloudMintPublicKey( publicKey: string, ): Effect.Effect { return Effect.try({ - try: () => createPublicKey(publicKey.replace(/\\n/g, "\n")), + try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), catch: () => new EnvironmentHttpBadRequestError({ message: "Cloud mint public key must be a valid Ed25519 public key.", diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 3b0cef13bf9..665447589eb 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import { dirname } from "node:path"; +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"; @@ -76,7 +76,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; - yield* fileSystem.makeDirectory(dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.makeDirectory(NodePath.dirname(environmentIdPath), { recursive: true }); yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); const writeAttempts: string[] = []; const failingFileSystemLayer = FileSystem.layerNoop({ diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 3ff9a42390e..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"; @@ -164,7 +164,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ } function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { - const result = spawnSync("git", args, { + const result = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8", }); @@ -254,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"]); }); @@ -459,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}`], { @@ -916,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(); @@ -1026,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"]); @@ -1348,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, { @@ -1383,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({ @@ -1426,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, { @@ -1454,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({ @@ -1514,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({ @@ -1597,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, { @@ -1626,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: { @@ -1697,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"]); @@ -1725,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, { @@ -1760,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"]); @@ -1805,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(); @@ -1982,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"]); @@ -2200,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"]); @@ -2252,7 +2252,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(peerDir, ["clone", remoteDir, "."]); yield* runGit(peerDir, ["config", "user.email", "peer@example.com"]); yield* runGit(peerDir, ["config", "user.name", "Peer User"]); - fs.writeFileSync(path.join(peerDir, "remote.txt"), "remote\n"); + 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"]); @@ -2265,7 +2265,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "feature/remote-base", "origin/main", ]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + 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"]); @@ -2304,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"]); @@ -2376,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"]); @@ -2556,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"]); @@ -2597,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"]); @@ -2655,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"]); @@ -2702,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"]); @@ -2730,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", @@ -2747,7 +2747,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-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"]); @@ -2802,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"]); @@ -2864,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"]); @@ -2917,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, [ @@ -2969,11 +2969,15 @@ 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: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; @@ -3004,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); @@ -3024,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"]); @@ -3084,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"]); @@ -3142,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); @@ -3179,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(), @@ -3196,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"]); @@ -3236,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); }), ); @@ -3276,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 }, ); @@ -3339,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 }, ); @@ -3392,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/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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 07c543264f7..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, @@ -198,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", @@ -206,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; @@ -267,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 }); } } }); @@ -395,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, @@ -456,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"), @@ -554,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", @@ -628,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"), @@ -761,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"), @@ -829,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); @@ -963,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/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8041bc66dd3..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, @@ -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); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 2aaa7ea9a33..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, @@ -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,13 +213,13 @@ 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), 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/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index fd49edf0529..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"; @@ -69,7 +69,7 @@ const checkNodeSqliteCompat = () => { const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, - openDatabase: () => DatabaseSync, + openDatabase: () => NodeSqlite.DatabaseSync, ): Effect.fn.Return { yield* checkNodeSqliteCompat(); @@ -86,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; @@ -113,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 { @@ -220,7 +224,7 @@ const make = ( makeWithDatabase( options, () => - new DatabaseSync(options.filename, { + new NodeSqlite.DatabaseSync(options.filename, { readOnly: options.readonly ?? false, allowExtension: options.allowExtension ?? false, }), @@ -236,7 +240,7 @@ const makeMemory = ( readonly: false, }, () => { - const database = new DatabaseSync(":memory:", { + const database = new NodeSqlite.DatabaseSync(":memory:", { allowExtension: config.allowExtension ?? false, }); return database; diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 9216c696008..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"; @@ -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/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916c9d077dd..4d22a2c1f8d 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 { @@ -395,7 +395,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 +649,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 +657,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => - rmSync(baseDir, { + NodeFS.rmSync(baseDir, { recursive: true, force: true, }), @@ -674,9 +674,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, 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/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/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index c7358edd55d..94faac60647 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, @@ -746,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); }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 0b1f99d3c11..f2c317a9127 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.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 { ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; @@ -31,8 +31,8 @@ function parseLogLine(line: string) { describe("EventNdjsonLogger", () => { 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 +51,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 +70,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 +79,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 +93,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 +107,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 +136,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 +149,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 +183,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 +200,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..c934abbfe3d 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -6,8 +6,8 @@ * 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"; @@ -178,7 +178,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 }; @@ -211,7 +211,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/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/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fbb8acfb9e6..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, @@ -644,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({ @@ -706,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)), ); @@ -714,8 +714,10 @@ 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 = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), @@ -834,7 +836,7 @@ 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)), ); @@ -1283,8 +1285,10 @@ 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 = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), @@ -1379,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)), ); @@ -1387,8 +1391,10 @@ 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 = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), @@ -1477,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)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index c5d60a69a22..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"; @@ -229,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"); @@ -266,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/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index a2c44f0ac1b..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"; @@ -13,8 +13,8 @@ import { describe, expect } from "vite-plus/test"; 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]; @@ -347,8 +347,8 @@ 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.AcpSessionRuntime; yield* runtime.start(); @@ -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/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 1018d123bb7..8937844f613 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,9 +1,9 @@ // @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 * 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"; @@ -25,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) => @@ -203,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, @@ -240,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, @@ -276,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, @@ -336,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, @@ -373,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, @@ -454,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", @@ -463,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, @@ -497,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", @@ -510,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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index eb2b2c3f2fa..e6b26efb3ff 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { @@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("signs the activity publish JWT and rejects tampering", async () => { - const keyPair = generateKeyPairSync("ed25519", { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 19988b20213..2202d30b837 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,7 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { generateKeyPairSync, type KeyObject, sign } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { AuthAccessTokenType, @@ -950,14 +950,14 @@ const makeDpopProof = (input: { readonly iat: number; readonly accessToken?: string; readonly jti?: string; - readonly privateKey?: KeyObject; + readonly privateKey?: NodeCrypto.KeyObject; readonly publicJwk?: DpopPublicJwk; }) => { const keyPair = input.privateKey && input.publicJwk ? { privateKey: input.privateKey, publicJwk: input.publicJwk } : (() => { - const { privateKey, publicKey } = generateKeyPairSync("ec", { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256", }); return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; @@ -978,7 +978,7 @@ const makeDpopProof = (input: { ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), }), ).toString("base64url"); - const signature = sign("sha256", Buffer.from(`${header}.${payload}`), { + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key: keyPair.privateKey, dsaEncoding: "ieee-p1363", }).toString("base64url"); @@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: { const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); const signingInput = `${header}.${encodedPayload}`; return { - proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, }; }; @@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: { const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); const signingInput = `${header}.${encodedPayload}`; return { - proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, }; }; @@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); @@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const cloudKeyPair = generateKeyPairSync("ed25519", { + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index e7b5406e7b9..7518901bfdd 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,4 +1,4 @@ -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -11,7 +11,7 @@ import * as PtyAdapter from "./PtyAdapter.ts"; 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; diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index 5365d920471..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"; @@ -21,8 +21,8 @@ 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("'", `'"'"'`)}'`; @@ -33,10 +33,10 @@ const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(proces }).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,7 +50,7 @@ function makeAcpAgentWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(agentPath, 0o755); + NodeFS.chmodSync(agentPath, 0o755); return agentPath; } @@ -59,10 +59,10 @@ function withFakeAcpAgent( 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/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 5df012cca85..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"; @@ -18,8 +18,8 @@ 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("'", `'"'"'`)}'`; @@ -30,10 +30,10 @@ const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process. }).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,7 +47,7 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { ].join("\n"), "utf8", ); - chmodSync(grokPath, 0o755); + NodeFS.chmodSync(grokPath, 0o755); return grokPath; } @@ -56,10 +56,10 @@ function withFakeAcpGrok( 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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e0c19bd3428..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"; @@ -651,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, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 7d6005f030d..a08350ed959 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,13 +1,14 @@ // @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 * as ServerConfig from "../config.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -15,6 +16,11 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); + const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(WorkspacePaths.layer), @@ -376,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 398b3d951b3..cdb26a38bc7 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import { readdir } from "node:fs/promises"; -import { homedir } from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -98,10 +98,10 @@ export class WorkspaceEntries extends Context.Service< function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } @@ -176,7 +176,7 @@ export const make = Effect.gen(function* () { const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); const dirents = yield* Effect.tryPromise({ - try: () => readdir(parentPath, { withFileTypes: true }), + try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), catch: (cause) => new WorkspaceEntriesReadDirectoryError({ cwd: input.cwd, diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 48e02c89cae..8cd176db3dd 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -7,7 +7,7 @@ * * @module WorkspaceFileSystem */ -import { open, realpath } from "node:fs/promises"; +import * as NodeFSP from "node:fs/promises"; import type { ProjectReadFileInput, @@ -89,8 +89,8 @@ export const make = Effect.gen(function* () { return yield* Effect.tryPromise({ try: async () => { const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - realpath(input.cwd), - realpath(target.absolutePath), + NodeFSP.realpath(input.cwd), + NodeFSP.realpath(target.absolutePath), ]); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( @@ -101,7 +101,7 @@ export const make = Effect.gen(function* () { throw new Error("Workspace file path resolves outside the project root."); } - const handle = await open(realTargetPath, "r"); + const handle = await NodeFSP.open(realTargetPath, "r"); try { const stat = await handle.stat(); if (!stat.isFile()) { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 8b6b685524b..85e3db561c4 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -6,7 +6,7 @@ * * @module WorkspacePaths */ -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -105,10 +105,10 @@ function toPosixRelativePath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index b8db9e16a36..400785be043 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -1,5 +1,6 @@ import { definePlugin } from "@oxlint/plugins"; +import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; @@ -9,6 +10,7 @@ export default definePlugin({ name: "t3code", }, rules: { + "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts new file mode 100644 index 00000000000..c097264ba8e --- /dev/null +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts @@ -0,0 +1,63 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/namespace-node-imports"); + +describe("t3code/namespace-node-imports", () => { + rule.valid( + "allows canonical Node namespaces", + ` + import * as NodeFS from "node:fs"; + import * as NodeFSP from "node:fs/promises"; + import * as NodeAssert from "node:assert/strict"; + import * as NodeChildProcess from "node:child_process"; + import * as NodeTimersPromises from "node:timers/promises"; + import type * as NodeStream from "node:stream"; + + NodeAssert.ok(NodeChildProcess.spawn && NodeTimersPromises.setTimeout); + export const read = NodeFS.readFileSync; + export const readAsync = NodeFSP.readFile; + export type Input = NodeStream.Readable; + `, + ); + + rule.valid( + "does not apply to non-Node packages", + ` + import { BrowserWindow } from "electron"; + `, + ); + + rule.invalid( + "reports named imports", + ` + import { readFile } from "node:fs/promises"; + `, + (output) => { + assert.match(output, /namespace named NodeFSP/); + }, + ); + + rule.invalid( + "reports default imports", + ` + import path from "node:path"; + `, + (output) => { + assert.match(output, /namespace named NodePath/); + }, + ); + + rule.invalid( + "reports non-canonical namespace aliases", + ` + import * as Crypto from "node:crypto"; + import * as NodeOs from "node:os"; + `, + (output) => { + assert.match(output, /namespace named NodeCrypto/); + assert.match(output, /namespace named NodeOS/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.ts new file mode 100644 index 00000000000..07d73dcf6b6 --- /dev/null +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.ts @@ -0,0 +1,76 @@ +import { defineRule } from "@oxlint/plugins"; + +const NODE_MODULE_ALIASES = new Map([ + ["assert/strict", "Assert"], + ["fs/promises", "FSP"], +]); + +const NODE_SEGMENT_ALIASES = new Map([ + ["fs", "FS"], + ["os", "OS"], + ["url", "URL"], + ["vm", "VM"], +]); + +const toPascalCase = (value: string) => + value + .split(/[_-]/u) + .filter((segment) => segment.length > 0) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(""); + +const expectedNamespaceAlias = (source: string) => { + const moduleName = source.slice("node:".length); + const knownAlias = NODE_MODULE_ALIASES.get(moduleName); + if (knownAlias !== undefined) return `Node${knownAlias}`; + + return `Node${moduleName + .split("/") + .map((segment) => NODE_SEGMENT_ALIASES.get(segment) ?? toPascalCase(segment)) + .join("")}`; +}; + +const literalStringValue = (node: unknown): string | undefined => { + if (typeof node !== "object" || node === null) return undefined; + if (!("type" in node) || node.type !== "Literal") return undefined; + if (!("value" in node) || typeof node.value !== "string") return undefined; + return node.value; +}; + +const identifierName = (node: unknown): string | undefined => { + if (typeof node !== "object" || node === null) return undefined; + if (!("type" in node) || node.type !== "Identifier") return undefined; + if (!("name" in node) || typeof node.name !== "string") return undefined; + return node.name; +}; + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: "Require canonical namespace imports for Node.js built-in modules.", + }, + }, + create(context) { + return { + ImportDeclaration(node) { + const source = literalStringValue(node.source); + if (source === undefined || !source.startsWith("node:")) return; + + const expectedAlias = expectedNamespaceAlias(source); + const namespaceImport = + node.specifiers.length === 1 && node.specifiers[0]?.type === "ImportNamespaceSpecifier" + ? node.specifiers[0] + : undefined; + const actualAlias = identifierName(namespaceImport?.local); + + if (actualAlias === expectedAlias) return; + + context.report({ + node, + message: `Import ${source} as a namespace named ${expectedAlias}.`, + }); + }, + }; + }, +}); diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index 8e1d1019e1d..e19d5cd0efa 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.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"; export interface RotatingFileSinkOptions { readonly filePath: string; @@ -29,7 +29,7 @@ export class RotatingFileSink { this.maxFiles = options.maxFiles; this.throwOnError = options.throwOnError ?? false; - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); this.pruneOverflowBackups(); this.currentSize = this.readCurrentSize(); } @@ -43,7 +43,7 @@ export class RotatingFileSink { this.rotate(); } - fs.appendFileSync(this.filePath, buffer); + NodeFS.appendFileSync(this.filePath, buffer); this.currentSize += buffer.length; if (this.currentSize > this.maxBytes) { @@ -60,20 +60,20 @@ export class RotatingFileSink { private rotate(): void { try { const oldest = this.withSuffix(this.maxFiles); - if (fs.existsSync(oldest)) { - fs.rmSync(oldest, { force: true }); + if (NodeFS.existsSync(oldest)) { + NodeFS.rmSync(oldest, { force: true }); } for (let index = this.maxFiles - 1; index >= 1; index -= 1) { const source = this.withSuffix(index); const target = this.withSuffix(index + 1); - if (fs.existsSync(source)) { - fs.renameSync(source, target); + if (NodeFS.existsSync(source)) { + NodeFS.renameSync(source, target); } } - if (fs.existsSync(this.filePath)) { - fs.renameSync(this.filePath, this.withSuffix(1)); + if (NodeFS.existsSync(this.filePath)) { + NodeFS.renameSync(this.filePath, this.withSuffix(1)); } this.currentSize = 0; @@ -87,13 +87,13 @@ export class RotatingFileSink { private pruneOverflowBackups(): void { try { - const dir = path.dirname(this.filePath); - const baseName = path.basename(this.filePath); - for (const entry of fs.readdirSync(dir)) { + const dir = NodePath.dirname(this.filePath); + const baseName = NodePath.basename(this.filePath); + for (const entry of NodeFS.readdirSync(dir)) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; - fs.rmSync(path.join(dir, entry), { force: true }); + NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); } } catch { if (this.throwOnError) { @@ -104,7 +104,7 @@ export class RotatingFileSink { private readCurrentSize(): number { try { - return fs.statSync(this.filePath).size; + return NodeFS.statSync(this.filePath).size; } catch { return 0; } diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 5eab78b83d5..cf2f2417ff4 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { execFileSync } from "node:child_process"; -import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,7 +26,7 @@ type ExecFileSyncLike = ( function canExecuteFile(filePath: string): boolean { try { - accessSync(filePath, fileSystemConstants.X_OK); + NodeFS.accessSync(filePath, NodeFS.constants.X_OK); return true; } catch { return false; @@ -108,7 +108,7 @@ function resolveSpawnExecutableWithNode( ); const isExecutable = (candidate: string) => { try { - if (!statSync(candidate).isFile()) return false; + if (!NodeFS.statSync(candidate).isFile()) return false; if (platform === "win32") { return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); } @@ -192,13 +192,13 @@ export function extractPathFromShellOutput(output: string): string | null { export function readPathFromLoginShell( shell: string, - execFile: ExecFileSyncLike = execFileSync, + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, ): string | undefined { return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } export function readPathFromLaunchctl( - execFile: ExecFileSyncLike = execFileSync, + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, ): string | undefined { try { return trimNonEmpty( @@ -305,7 +305,7 @@ export type ShellEnvironmentReader = ( export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( shell, names, - execFile = execFileSync, + execFile = NodeChildProcess.execFileSync, ) => { if (names.length === 0) { return {}; @@ -371,7 +371,7 @@ export function readEnvironmentFromWindowsShell( const execFile: ExecFileSyncLike = typeof optionsOrExecFile === "function" ? optionsOrExecFile - : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + : (maybeExecFile ?? (NodeChildProcess.execFileSync as ExecFileSyncLike)); const command = buildWindowsEnvironmentCaptureCommand(names); const args = [ "-NoLogo", diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index aa48a1b357e..10927b43089 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,4 +1,4 @@ -import * as Crypto from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -74,7 +74,10 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string } export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { - return Crypto.createHash("sha256").update(targetConnectionKey(target)).digest("hex").slice(0, 16); + return NodeCrypto.createHash("sha256") + .update(targetConnectionKey(target)) + .digest("hex") + .slice(0, 16); } export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8aa95c3e68d..2d708057e38 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -482,7 +482,7 @@ const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinari path.join(stageAppDir, "node_modules", "@clerk", "electron-passkeys", "index.js"), ); const packageDir = path.dirname(packageEntryPath); - const packageRequire = createRequire(packageEntryPath); + const packageRequire = NodeModule.createRequire(packageEntryPath); for (const artifact of resolveClerkPasskeyNativeArtifacts(platform, arch)) { const sourcePath = yield* Effect.try({ diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index 6f6e8315664..62d383484cf 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; @@ -10,7 +10,7 @@ const temporaryDirectories: string[] = []; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); + NodeFS.rmSync(directory, { recursive: true, force: true }); } }); @@ -43,12 +43,12 @@ describe("loadRepoEnv", () => { it("applies process, root local, and root precedence in that order", () => { const repoRoot = makeTemporaryDirectory(); - writeFileSync( - join(repoRoot, ".env"), + NodeFS.writeFileSync( + NodePath.join(repoRoot, ".env"), "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_root\nT3CODE_RELAY_URL=https://root.example.test\n", ); - writeFileSync( - join(repoRoot, ".env.local"), + NodeFS.writeFileSync( + NodePath.join(repoRoot, ".env.local"), "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_local\nT3CODE_RELAY_URL=https://local.example.test\n", ); @@ -148,7 +148,7 @@ describe("loadRepoEnv", () => { }); function makeTemporaryDirectory() { - const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-public-config-")); temporaryDirectories.push(directory); return directory; } diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 2fe67164666..45d2dd436b6 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,21 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off -import { execFileSync } from "node:child_process"; -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const workspaceFiles = [ "package.json", @@ -44,26 +36,26 @@ const workspaceFiles = [ function copyWorkspaceManifestFixture(targetRoot: string): void { for (const relativePath of workspaceFiles) { - const sourcePath = resolve(repoRoot, relativePath); - const destinationPath = resolve(targetRoot, relativePath); - mkdirSync(dirname(destinationPath), { recursive: true }); - cpSync(sourcePath, destinationPath); + const sourcePath = NodePath.resolve(repoRoot, relativePath); + const destinationPath = NodePath.resolve(targetRoot, relativePath); + NodeFS.mkdirSync(NodePath.dirname(destinationPath), { recursive: true }); + NodeFS.cpSync(sourcePath, destinationPath); } - const patchesDirectory = resolve(repoRoot, "patches"); - if (existsSync(patchesDirectory)) { - cpSync(patchesDirectory, resolve(targetRoot, "patches"), { recursive: true }); + const patchesDirectory = NodePath.resolve(repoRoot, "patches"); + if (NodeFS.existsSync(patchesDirectory)) { + NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); } } function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, "latest-mac.yml"); - const x64Path = resolve(assetDirectory, "latest-mac-x64.yml"); + const arm64Path = NodePath.resolve(assetDirectory, "latest-mac.yml"); + const x64Path = NodePath.resolve(assetDirectory, "latest-mac-x64.yml"); - writeFileSync( + NodeFS.writeFileSync( arm64Path, `version: 9.9.9-smoke.0 files: @@ -79,7 +71,7 @@ releaseDate: '2026-03-08T10:32:14.587Z' `, ); - writeFileSync( + NodeFS.writeFileSync( x64Path, `version: 9.9.9-smoke.0 files: @@ -102,13 +94,13 @@ function writeWindowsManifestFixtures( targetRoot: string, channel: string, ): { arm64Path: string; x64Path: string } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, `${channel}-win-arm64.yml`); - const x64Path = resolve(assetDirectory, `${channel}-win-x64.yml`); + const arm64Path = NodePath.resolve(assetDirectory, `${channel}-win-arm64.yml`); + const x64Path = NodePath.resolve(assetDirectory, `${channel}-win-x64.yml`); - writeFileSync( + NodeFS.writeFileSync( arm64Path, `version: 9.9.9-smoke.0 files: @@ -124,7 +116,7 @@ releaseDate: '2026-03-08T10:32:14.587Z' `, ); - writeFileSync( + NodeFS.writeFileSync( x64Path, `version: 9.9.9-smoke.0 files: @@ -147,11 +139,11 @@ function writeWindowsBuilderDebugFixtures(targetRoot: string): { arm64Path: string; x64Path: string; } { - const assetDirectory = resolve(targetRoot, "release-assets"); - mkdirSync(assetDirectory, { recursive: true }); + const assetDirectory = NodePath.resolve(targetRoot, "release-assets"); + NodeFS.mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, "builder-debug-win-arm64.yml"); - const x64Path = resolve(assetDirectory, "builder-debug-win-x64.yml"); + const arm64Path = NodePath.resolve(assetDirectory, "builder-debug-win-arm64.yml"); + const x64Path = NodePath.resolve(assetDirectory, "builder-debug-win-x64.yml"); const debugFixture = `arm64: firstOrDefaultFilePatterns: - '**/*' @@ -160,8 +152,8 @@ nsis: !include "example.nsh" `; - writeFileSync(arm64Path, debugFixture); - writeFileSync(x64Path, debugFixture); + NodeFS.writeFileSync(arm64Path, debugFixture); + NodeFS.writeFileSync(x64Path, debugFixture); return { arm64Path, x64Path }; } @@ -172,13 +164,13 @@ function assertContains(haystack: string, needle: string, message: string): void } function assertExists(path: string, message: string): void { - if (!existsSync(path)) { + if (!NodeFS.existsSync(path)) { throw new Error(message); } } function assertPackageVersion(path: string, version: string): void { - const packageJson = JSON.parse(readFileSync(path, "utf8")) as { + const packageJson = JSON.parse(NodeFS.readFileSync(path, "utf8")) as { readonly version?: unknown; }; @@ -188,20 +180,20 @@ function assertPackageVersion(path: string, version: string): void { } function assertMissing(path: string, message: string): void { - if (existsSync(path)) { + if (NodeFS.existsSync(path)) { throw new Error(message); } } -const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); +const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-release-smoke-")); try { copyWorkspaceManifestFixture(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/update-release-package-versions.ts"), + NodePath.resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot, @@ -212,14 +204,14 @@ try { }, ); - rmSync(resolve(tempRoot, "pnpm-lock.yaml"), { force: true }); + NodeFS.rmSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), { force: true }); - execFileSync("vp", ["install", "--lockfile-only", "--ignore-scripts"], { + NodeChildProcess.execFileSync("vp", ["install", "--lockfile-only", "--ignore-scripts"], { cwd: tempRoot, stdio: "inherit", }); - const lockfile = readFileSync(resolve(tempRoot, "pnpm-lock.yaml"), "utf8"); + const lockfile = NodeFS.readFileSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), "utf8"); assertContains(lockfile, "lockfileVersion:", "Expected pnpm-lock.yaml to be regenerated."); for (const relativePath of [ @@ -228,13 +220,13 @@ try { "apps/web/package.json", "packages/contracts/package.json", ]) { - assertPackageVersion(resolve(tempRoot, relativePath), "9.9.9-smoke.0"); + assertPackageVersion(NodePath.resolve(tempRoot, relativePath), "9.9.9-smoke.0"); } - const nightlyReleaseMetadata = execFileSync( + const nightlyReleaseMetadata = NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/resolve-nightly-release.ts"), + NodePath.resolve(repoRoot, "scripts/resolve-nightly-release.ts"), "--date", "20260413", "--run-number", @@ -266,10 +258,10 @@ try { ); const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( process.execPath, [ - resolve(repoRoot, "scripts/merge-update-manifests.ts"), + NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"), "--platform", "mac", arm64Path, @@ -281,7 +273,7 @@ try { }, ); - const mergedManifest = readFileSync(arm64Path, "utf8"); + const mergedManifest = NodeFS.readFileSync(arm64Path, "utf8"); assertContains( mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", @@ -297,21 +289,21 @@ try { tempRoot, "latest", ); - const mergedWindowsManifestPath = resolve(tempRoot, "release-assets/latest.yml"); + const mergedWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/latest.yml"); const { arm64Path: nightlyWinArm64Path, x64Path: nightlyWinX64Path } = writeWindowsManifestFixtures(tempRoot, "nightly"); - const mergedNightlyWindowsManifestPath = resolve(tempRoot, "release-assets/nightly.yml"); + const mergedNightlyWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/nightly.yml"); const { arm64Path: previewWinArm64Path, x64Path: previewWinX64Path } = writeWindowsManifestFixtures(tempRoot, "preview"); - const mergedPreviewWindowsManifestPath = resolve(tempRoot, "release-assets/preview.yml"); + const mergedPreviewWindowsManifestPath = NodePath.resolve(tempRoot, "release-assets/preview.yml"); const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = writeWindowsBuilderDebugFixtures(tempRoot); - execFileSync( + NodeChildProcess.execFileSync( "bash", [ "-lc", ` - release_assets_dir=${JSON.stringify(resolve(tempRoot, "release-assets"))} + release_assets_dir=${JSON.stringify(NodePath.resolve(tempRoot, "release-assets"))} shopt -s nullglob found_windows_manifest=false for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do @@ -327,7 +319,7 @@ try { fi found_windows_manifest=true - ${JSON.stringify(process.execPath)} ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + ${JSON.stringify(process.execPath)} ${JSON.stringify(NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ "$arm64_manifest" \ "$x64_manifest" \ "$output_manifest" @@ -346,7 +338,7 @@ try { }, ); - const mergedWindowsManifest = readFileSync(mergedWindowsManifestPath, "utf8"); + const mergedWindowsManifest = NodeFS.readFileSync(mergedWindowsManifestPath, "utf8"); assertContains( mergedWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -357,7 +349,10 @@ try { "T3-Code-9.9.9-smoke.0-x64.exe", "Merged Windows manifest is missing the x64 asset.", ); - const mergedNightlyWindowsManifest = readFileSync(mergedNightlyWindowsManifestPath, "utf8"); + const mergedNightlyWindowsManifest = NodeFS.readFileSync( + mergedNightlyWindowsManifestPath, + "utf8", + ); assertContains( mergedNightlyWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -368,7 +363,10 @@ try { "T3-Code-9.9.9-smoke.0-x64.exe", "Merged nightly Windows manifest is missing the x64 asset.", ); - const mergedPreviewWindowsManifest = readFileSync(mergedPreviewWindowsManifestPath, "utf8"); + const mergedPreviewWindowsManifest = NodeFS.readFileSync( + mergedPreviewWindowsManifestPath, + "utf8", + ); assertContains( mergedPreviewWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -411,5 +409,5 @@ try { Effect.runSync(Console.log("Release smoke checks passed.")); } finally { - rmSync(tempRoot, { recursive: true, force: true }); + NodeFS.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/vite.config.ts b/vite.config.ts index 1a8029b1656..967521100a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,11 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; -import { fileURLToPath } from "node:url"; +import * as NodeURL from "node:url"; export default defineConfig({ resolve: { alias: { - "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), + "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), }, }, test: { @@ -111,6 +111,7 @@ export default defineConfig({ "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", + "t3code/namespace-node-imports": "error", }, options: { // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. From b19fc1b87b22a3c923e60c2ef2a1dae336279924 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:24:04 -0700 Subject: [PATCH 068/142] [codex] refactor desktop Electron Effect services (#3178) Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 3 - .../src/app/DesktopConnectionCatalogStore.ts | 13 +- apps/desktop/src/app/DesktopLifecycle.ts | 6 +- apps/desktop/src/electron/ElectronApp.ts | 71 +++--- apps/desktop/src/electron/ElectronDialog.ts | 29 ++- apps/desktop/src/electron/ElectronMenu.ts | 224 +++++++++--------- apps/desktop/src/electron/ElectronProtocol.ts | 34 +-- .../src/electron/ElectronSafeStorage.ts | 73 +++--- apps/desktop/src/electron/ElectronShell.ts | 17 +- apps/desktop/src/electron/ElectronTheme.ts | 19 +- .../src/electron/ElectronUpdater.test.ts | 1 + apps/desktop/src/electron/ElectronUpdater.ts | 103 ++++---- apps/desktop/src/electron/ElectronWindow.ts | 56 ++--- apps/desktop/src/main.ts | 8 +- .../settings/DesktopSavedEnvironments.test.ts | 3 - .../src/settings/DesktopSavedEnvironments.ts | 12 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 4 +- .../src/updates/DesktopUpdates.test.ts | 4 +- .../src/window/DesktopApplicationMenu.test.ts | 6 +- .../src/window/DesktopApplicationMenu.ts | 6 +- apps/desktop/src/window/DesktopWindow.ts | 12 +- 21 files changed, 351 insertions(+), 353 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 26c0c8f8943..e0be7f39b39 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -237,7 +237,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, ); - assert.equal(error.operation, "decode-catalog-document"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); @@ -272,7 +271,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.operation, "read-catalog"); assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); assert.strictEqual(error.cause, permissionError); assert.equal( @@ -368,7 +366,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, ); - assert.equal(error.operation, "decode-encrypted-catalog"); assert.equal(error.resource, "encryptedCatalog"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 7eaf3ec7cf6..8467fe3f077 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -92,7 +92,6 @@ const writeError = ( export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { - operation: Schema.Literal("decode-encrypted-catalog"), resource: Schema.Literal("encryptedCatalog"), catalogPath: Schema.String, cause: Schema.Defect(), @@ -106,7 +105,6 @@ export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedError export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", { - operation: Schema.Literal("read-catalog"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +117,6 @@ export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorCl export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDocumentDecodeError", { - operation: Schema.Literal("decode-catalog-document"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -167,16 +164,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError + | ElectronSafeStorage.ElectronSafeStorageError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - | DesktopConnectionCatalogStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError + DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError >; readonly clear: Effect.Effect; } @@ -190,7 +184,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDecodeError({ - operation: "decode-encrypted-catalog", resource: "encryptedCatalog", catalogPath, cause, @@ -212,7 +205,6 @@ const readDocument = ( ? Effect.succeed(null) : Effect.fail( new DesktopConnectionCatalogStoreReadError({ - operation: "read-catalog", catalogPath, cause: error, }), @@ -226,7 +218,6 @@ const readDocument = ( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ - operation: "decode-catalog-document", catalogPath, cause, }), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index b62662ad27b..ad08d2f5a2e 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -8,7 +8,7 @@ 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"; @@ -37,7 +37,7 @@ export class DesktopLifecycle extends Context.Service< >()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = - DesktopObservability.makeComponentLogger("desktop-lifecycle"); + makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -122,7 +122,7 @@ function quitFromSignal( ); } -const make = DesktopLifecycle.of({ +export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 49b432fd5dd..3e894001e10 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -13,41 +13,40 @@ 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 ElectronApp extends Context.Service()( - "@t3tools/desktop/electron/ElectronApp", -) {} +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,7 +62,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -const make = ElectronApp.of({ +export const make = ElectronApp.of({ metadata: Effect.sync(() => ({ appVersion: Electron.app.getVersion(), appPath: Electron.app.getAppPath(), 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 2ffda3dc507..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,114 +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, - }); - icon.setTemplateImage(true); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + icon.setTemplateImage(true); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } - return destructiveMenuIconCache; - }; + return destructiveMenuIconCache; + }; - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } - } + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } - template.push(itemOption); + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } } - return template; - }; + template.push(itemOption); + } - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - 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.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 3a3e9f180f7..4c80c2c4900 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,8 +1,8 @@ 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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -23,13 +23,14 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } -export class ElectronProtocolRegistrationError extends Data.TaggedError( +export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", -)<{ - readonly scheme: string; - readonly cause: unknown; -}> { - override get message() { + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { return `Failed to register ${this.scheme}: protocol.`; } } @@ -41,15 +42,14 @@ export interface DesktopProtocolRegistrationInput { readonly clerkFrontendApiHostname: string | undefined; } -export interface ElectronProtocolShape { - readonly registerDesktopProtocol: ( - input: DesktopProtocolRegistrationInput, - ) => Effect.Effect; -} - -export class ElectronProtocol extends Context.Service()( - "@t3tools/desktop/electron/ElectronProtocol", -) {} +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 @@ -114,7 +114,7 @@ async function proxyRequest( return withContentSecurityPolicy(response, contentSecurityPolicy); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 85313370547..76162c1647a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,56 +1,69 @@ -import * as Electron from "electron"; - +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( +import * as Electron from "electron"; + +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.ts b/apps/desktop/src/electron/ElectronTheme.ts index 1e23d228504..ef99a31067a 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -6,17 +6,16 @@ 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 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 class ElectronTheme extends Context.Service()( - "@t3tools/desktop/electron/ElectronTheme", -) {} - -const make = ElectronTheme.of({ +export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => Effect.suspend(() => { diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d2d3edd3696..43a3c84dcd4 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -73,6 +73,7 @@ describe("ElectronUpdater", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); assert.equal(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates."); } }).pipe(Effect.provide(ElectronUpdater.layer)), ); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 7f3edf02aa8..8a468a15c20 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,76 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( +const electronUpdaterErrorFields = { + cause: Schema.Defect(), +}; + +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to check for updates."; } } -export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to download the update."; } } -export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to quit and install the update."; } } -export type ElectronUpdaterError = - | ElectronUpdaterCheckForUpdatesError - | ElectronUpdaterDownloadUpdateError - | ElectronUpdaterQuitAndInstallError; +export const ElectronUpdaterError = Schema.Union([ + ElectronUpdaterCheckForUpdatesError, + ElectronUpdaterDownloadUpdateError, + ElectronUpdaterQuitAndInstallError, +]); +export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; +export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); -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 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); @@ -136,4 +145,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/main.ts b/apps/desktop/src/main.ts index a6ffd9cdab1..f4b32db07c7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -19,7 +19,7 @@ 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"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), + homeDirectory: homedir(), platform, processArch, ...metadata, @@ -106,7 +106,7 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronProtocol.layer, - DesktopSecretStorage.layer, + ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, ElectronUpdater.layer, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 4e3c8d8ba1d..abd25a39f5b 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -241,7 +241,6 @@ describe("DesktopSavedEnvironments", () => { .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.operation, "decode-secret"); assert.equal(error.environmentId, savedRegistryRecord.environmentId); assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); assert.equal(error.field, "encryptedBearerToken"); @@ -363,7 +362,6 @@ describe("DesktopSavedEnvironments", () => { registryError, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); - assert.equal(registryError.operation, "decode-registry"); assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); assert.exists(registryError.cause); const secretError = yield* savedEnvironments @@ -409,7 +407,6 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.operation, "read-registry"); assert.equal(error.registryPath, registryPath); assert.strictEqual(error.cause, permissionError); assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 490777e9e84..64c40d39f0e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -106,7 +106,6 @@ const writeError = ( export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { - operation: Schema.Literal("read-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +118,6 @@ export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsDocumentDecodeError", { - operation: Schema.Literal("decode-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -132,7 +130,6 @@ export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedEr export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentSecretDecodeError", { - operation: Schema.Literal("decode-secret"), environmentId: Schema.String, registryPath: Schema.String, field: Schema.Literal("encryptedBearerToken"), @@ -155,13 +152,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, @@ -248,7 +243,6 @@ function readRegistryDocument( ? Effect.succeed(null) : Effect.fail( new DesktopSavedEnvironmentsReadError({ - operation: "read-registry", registryPath, cause: error, }), @@ -262,7 +256,6 @@ function readRegistryDocument( Effect.mapError( (cause) => new DesktopSavedEnvironmentsDocumentDecodeError({ - operation: "decode-registry", registryPath, cause, }), @@ -329,7 +322,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopSavedEnvironmentSecretDecodeError({ - operation: "decode-secret", environmentId, registryPath, field: "encryptedBearerToken", 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/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/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f3444c629f7..04a1971ce46 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,14 +46,14 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, -} satisfies ElectronApp.ElectronAppShape); +} satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialogShape); +} satisfies ElectronDialog.ElectronDialog["Service"]); const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), @@ -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 733c1f5494d..cfe4f5702a1 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; 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"; @@ -26,9 +26,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, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1822bb0c98e..e6cfce3c54f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,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 { getDesktopUrl } from "../electron/ElectronProtocol.ts"; -import * as IpcChannels from "../ipc/channels.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 @@ -57,7 +57,7 @@ export class DesktopWindow extends Context.Service< >()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = - DesktopObservability.makeComponentLogger("desktop-window"); + makeComponentLogger("desktop-window"); function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, @@ -380,7 +380,7 @@ export 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)); }; From 97e5cd3bf7bbee427a177d5017aa2d250429bbf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:34:52 -0700 Subject: [PATCH 069/142] [codex] align server auth Effect services (#3180) Co-authored-by: codex --- apps/desktop/src/main.ts | 4 +- apps/server/src/assets/AssetAccess.ts | 12 +- apps/server/src/auth/EnvironmentAuth.test.ts | 27 +- apps/server/src/auth/EnvironmentAuth.ts | 714 ++++++++++++------ .../src/auth/EnvironmentAuthAdmin.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 20 +- .../server/src/auth/PairingGrantStore.test.ts | 10 +- apps/server/src/auth/PairingGrantStore.ts | 232 ++++-- .../server/src/auth/ServerSecretStore.test.ts | 8 +- apps/server/src/auth/ServerSecretStore.ts | 205 +++-- apps/server/src/auth/SessionStore.test.ts | 8 +- apps/server/src/auth/SessionStore.ts | 493 ++++++++---- apps/server/src/auth/dpop.test.ts | 16 +- apps/server/src/auth/dpop.ts | 30 +- apps/server/src/auth/http.ts | 78 +- apps/server/src/cloud/environmentKeys.test.ts | 8 +- apps/server/src/cloud/environmentKeys.ts | 35 +- apps/server/src/cloud/http.test.ts | 28 +- apps/server/src/cloud/http.ts | 232 +++--- apps/server/src/http.ts | 10 +- .../src/relay/AgentAwarenessRelay.test.ts | 12 +- apps/server/src/ws.ts | 10 +- 22 files changed, 1434 insertions(+), 764 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f4b32db07c7..b88eb18e57f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: homedir(), + homeDirectory: NodeOS.homedir(), platform, processArch, ...metadata, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cf3c40f57c7..873e9fc3d37 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,9 +19,9 @@ 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 * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "attachment": { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, @@ -225,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))); @@ -244,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)); @@ -255,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, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index b917cadb980..335e0685197 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -53,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); + } }), ); @@ -117,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 7dcc89761be..03009270e15 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( } 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 = ( 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 12b0060094a..b3c9b30f643 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -61,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())), ); @@ -85,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())), @@ -132,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({ @@ -149,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( @@ -183,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 c655a0f36b6..8a7a4d2e40f 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -7,17 +7,17 @@ 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 * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { @@ -29,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; @@ -64,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"; @@ -111,20 +198,9 @@ 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 config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); @@ -177,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* () { @@ -208,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({ @@ -223,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) { @@ -264,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( @@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -303,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, ]; @@ -370,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() { @@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }, revoke, consume, - } satisfies PairingGrantStoreShape; + }); }); -export const layer = Layer.effect(PairingGrantStore, make()).pipe( +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 f18e59e6293..d4411fb9f3b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -9,7 +9,7 @@ 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 = () => @@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + 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"); @@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + 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"); @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + 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"); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 0dc4a6bb544..5e9890c1ea2 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -9,49 +9,158 @@ 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 Schema.TaggedErrorClass()( - "SecretStoreError", +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", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...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, 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; -} + "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, }), ), @@ -59,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) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, + new SecretStoreReadError({ + resource: `secret ${name}`, cause, }), ), @@ -75,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, }), ), @@ -98,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, }), ), @@ -112,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* () { @@ -127,15 +236,15 @@ 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( Option.match({ @@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { crypto.randomBytes(bytes).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to generate random bytes for secret ${name}.`, + new SecretStoreRandomGenerationError({ + resource: `secret ${name}`, cause, }), ), Effect.flatMap((generated) => create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(isSecretStoreError, (error) => isSecretAlreadyExistsError(error) ? get(name).pipe( Effect.flatMap( @@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { onSome: Effect.succeed, onNone: () => Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, + new SecretStoreConcurrentReadError({ + resource: `secret ${name}`, }), ), }), @@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { 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, }), ), @@ -192,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 130222408a6..0dd5d797d19 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi const failingSessionLookupCredentialLayer = Layer.effect( SessionStore.SessionStore, - SessionStore.make(), + SessionStore.make, ).pipe( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), @@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessions = yield* SessionStore.SessionStore; const error = yield* Effect.flip(sessions.verify("not-a-session-token")); - expect(error._tag).toBe("SessionCredentialInvalidError"); + expect(error._tag).toBe("MalformedSessionTokenError"); expect(error.message).toContain("Malformed session token"); }).pipe(Effect.provide(makeSessionStoreLayer())), ); @@ -105,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 e1064c27904..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,7 +20,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { @@ -63,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); @@ -184,15 +428,9 @@ 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* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); @@ -238,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); @@ -272,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; @@ -299,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; @@ -321,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); @@ -367,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 { @@ -434,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) { @@ -463,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); @@ -472,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 { @@ -538,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); @@ -567,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({ @@ -587,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) { @@ -618,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, @@ -635,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(AuthSessions.layer), -); +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 6e1be00209d..71fb00b970a 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -169,10 +169,12 @@ 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, { @@ -201,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), ), ), @@ -231,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( @@ -265,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; @@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); }, traceRelayRequest, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + 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( @@ -310,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), ), ), @@ -335,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), ), ), @@ -348,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), ), ), @@ -362,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), ), ), @@ -375,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), ), ), @@ -392,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( @@ -409,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/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 5c20cd64ed3..48c44ccc48a 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -6,7 +6,7 @@ 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 = () => @@ -65,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", @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it ), getOrCreateRandom: unusedSecretStoreOperation, remove: unusedSecretStoreOperation, - } satisfies ServerSecretStore.ServerSecretStoreShape; + } satisfies ServerSecretStore.ServerSecretStore["Service"]; assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "winner-private", diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index f051d8265cb..1d0cde91bf4 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,47 +27,46 @@ 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 (Option.isNone(encoded)) { return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to decode environment signing key pair.", cause), - ), + 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( Option.match({ onSome: Effect.succeed, - onNone: () => - Effect.fail( - keyPairPersistenceError( - "Failed to read environment signing key pair after concurrent creation.", - ), - ), + onNone: () => Effect.fail(keyPairConcurrentReadError()), }), ), ) @@ -77,7 +76,7 @@ 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 (Option.isSome(existing)) { diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 58274c9d708..ed2e5a4cf75 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,8 +16,8 @@ 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", @@ -40,6 +40,30 @@ function makeSecretStore( }; } +it("preserves messages surfaced by cloud 500 responses", () => { + const cause = new Error("cloud operation failed"); + + expect([ + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, + ]).toEqual([ + "Could not verify the linked cloud account.", + "Could not read the linked cloud account.", + "Cloud linked user is not installed for this environment.", + "Failed to sign cloud link JWT.", + "Cloud mint public key is not installed for this environment.", + "Cloud relay issuer is not installed for this environment.", + "Failed to sign cloud health JWT.", + "Failed to sign cloud mint JWT.", + ]); +}); + describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 71be9f376d8..fc2adca9fbc 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -68,7 +68,7 @@ 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"; @@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = ); const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error.cause); + failEnvironmentCloudInternalError(error.message)(error); const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( @@ -126,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), @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { 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, }), ), @@ -239,19 +238,14 @@ function readInstalledCloudUserId( 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) => Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", - }), - ), + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -394,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, }), ), @@ -416,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* ( @@ -477,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 = ( @@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* CliState.setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -591,15 +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({ CloudCliCredentialRemovalError: failCloudCliTokenManagerError, CloudCliCredentialRefreshError: failCloudCliTokenManagerError, CloudCliCredentialReadError: failCloudCliTokenManagerError, CloudCliAuthorizationError: failCloudCliTokenManagerError, CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), }), ); @@ -637,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."), ), ); @@ -659,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."), ), ); @@ -680,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) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : 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) => - 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.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; @@ -777,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, }), ), @@ -794,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) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : 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) => - 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.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; @@ -899,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, }), ), @@ -914,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( diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0528d5e523d..ce9b498cb1f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -81,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); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index e6b26efb3ff..40ed694723d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -67,15 +67,15 @@ function makeMemorySecretStore() { Effect.sync(() => { const value = values.get(name); return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + })) 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); @@ -85,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)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e76b3f63d7a..03b609ddcfe 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1726,10 +1726,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, From 86db35ca6175c3340a18d151a486b77b82664ba7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:40:55 -0700 Subject: [PATCH 070/142] [codex] Structure mobile native static-check failures (#3302) Co-authored-by: codex --- scripts/mobile-native-static-check.test.ts | 18 ++++++++++++++++ scripts/mobile-native-static-check.ts | 25 ++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 scripts/mobile-native-static-check.test.ts diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts new file mode 100644 index 00000000000..9671ffbbc9f --- /dev/null +++ b/scripts/mobile-native-static-check.test.ts @@ -0,0 +1,18 @@ +import { assert, it } from "@effect/vitest"; + +import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts"; + +it("describes failed native static-analysis commands structurally", () => { + const error = new NativeStaticCheckCommandError({ + command: "swiftlint", + args: ["lint", "--strict"], + cwd: "/repo/apps/mobile", + exitCode: 2, + }); + + assert.equal(error.command, "swiftlint"); + assert.deepStrictEqual(error.args, ["lint", "--strict"]); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.exitCode, 2); + assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2."); +}); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index 4b43788a9ef..cbdf4be2bd0 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -4,12 +4,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -18,9 +18,19 @@ interface NativeStaticTool { readonly installHint: string; } -class NativeStaticCheckError extends Data.TaggedError("NativeStaticCheckError")<{ - readonly message: string; -}> {} +export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass()( + "NativeStaticCheckCommandError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Native static check command '${this.command}' exited with code ${this.exitCode}.`; + } +} const tools = [ { @@ -85,8 +95,11 @@ const runCommand = Effect.fn("runCommand")(function* ( const exitCode = Number(yield* child.exitCode); if (exitCode !== 0) { - return yield* new NativeStaticCheckError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new NativeStaticCheckCommandError({ + command, + args, + cwd, + exitCode, }); } }); From 20734d4a78e4acb1bc1fff8083df5f71165cc545 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:41:13 -0700 Subject: [PATCH 071/142] [codex] Structure macOS passkey signing failures (#3303) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 89 ++++++++++--- scripts/build-desktop-artifact.ts | 171 ++++++++++++++++++++----- 2 files changed, 213 insertions(+), 47 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b0d84bb12b5..f8c354a8599 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -6,10 +6,15 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { + BuildScriptError, createStageWorkspaceConfig, createStagePnpmConfig, createBuildConfig, DESKTOP_ASAR_UNPACK, + InvalidMacPasskeyRpDomainError, + InvalidMacPasskeyPublishableKeyError, + isMacPasskeySigningConfigurationError, + MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -214,23 +219,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("rejects incomplete macOS passkey signing configuration", () => { - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", - }), - /T3CODE_MACOS_PROVISIONING_PROFILE/u, - ); - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "https://example.clerk.accounts.dev/path", - }), - /Invalid passkey RP domain/u, + const captureError = (env: Readonly>) => { + try { + resolveMacPasskeySigningConfiguration(env); + } catch (error) { + return error; + } + return assert.fail("Expected passkey signing configuration to fail."); + }; + + const missingProfileError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", + }); + assert.instanceOf(missingProfileError, MissingMacPasskeyProvisioningProfileError); + assert.equal( + missingProfileError.message, + "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", ); + + const unsafeDomain = + "https://domain-user:domain-secret@example.clerk.accounts.dev/path?token=query-secret"; + const invalidDomainError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: unsafeDomain, + }); + assert.instanceOf(invalidDomainError, InvalidMacPasskeyRpDomainError); + assert.equal(invalidDomainError.reason, "scheme-not-allowed"); + assert.equal(invalidDomainError.inputLength, unsafeDomain.length); + assert.equal(invalidDomainError.message, "Invalid passkey RP domain (scheme-not-allowed)."); + assert.notProperty(invalidDomainError, "domain"); + assert.notProperty(invalidDomainError, "cause"); + const serializedInvalidDomainError = JSON.stringify(invalidDomainError); + assert.notInclude(serializedInvalidDomainError, unsafeDomain); + assert.notInclude(serializedInvalidDomainError, "domain-user"); + assert.notInclude(serializedInvalidDomainError, "domain-secret"); + assert.notInclude(serializedInvalidDomainError, "query-secret"); assert.throws( () => resolveMacPasskeySigningConfiguration({ @@ -240,6 +265,38 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), /Invalid passkey RP domain/u, ); + const invalidPublishableKeyError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_%", + }); + assert.instanceOf(invalidPublishableKeyError, InvalidMacPasskeyPublishableKeyError); + assert.ok(invalidPublishableKeyError.cause); + assert.equal(invalidPublishableKeyError.message, "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."); + assert.notProperty(invalidPublishableKeyError, "publishableKey"); + assert.notInclude(invalidPublishableKeyError.message, "pk_test_%"); + }); + + it("preserves known passkey signing configuration errors at the build boundary", () => { + const decodingCause = new Error("publishable-key-decode-failed"); + const knownError = new InvalidMacPasskeyPublishableKeyError({ cause: decodingCause }); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(knownError); + + assert.strictEqual(error, knownError); + assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); + assert.strictEqual(error.cause, decodingCause); + assert.isTrue(isMacPasskeySigningConfigurationError(error)); + }); + + it("wraps unknown passkey signing configuration defects without copying cause text", () => { + const secret = "pk_test_do-not-retain"; + const cause = new Error(secret); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(cause); + + assert.instanceOf(error, BuildScriptError); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); + assert.notInclude(error.message, secret); }); it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2d708057e38..6f13783f2d1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -126,10 +126,21 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ +export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + static fromMacPasskeySigningConfiguration( + cause: unknown, + ): MacPasskeySigningConfigurationError | BuildScriptError { + return isMacPasskeySigningConfigurationError(cause) + ? cause + : new BuildScriptError({ + message: "Failed to resolve macOS passkey signing configuration.", + cause, + }); + } +} const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( @@ -306,26 +317,128 @@ export interface MacPasskeySigningConfiguration { readonly provisioningProfilePath: string; } +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedErrorClass()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyProvisioningProfileError", + {}, +) { + override get message(): string { + return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile."; + } +} + +export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyDomainConfigurationError", + {}, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds."; + } +} + +export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyPublishableKeyError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."; + } +} + +export class MissingMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyRpDomainError", + {}, +) { + override get message(): string { + return "At least one Clerk passkey RP domain is required."; + } +} + +export const MacPasskeySigningConfigurationError = Schema.Union([ + InvalidMacPasskeyRpDomainError, + InvalidAppleTeamIdError, + MissingMacPasskeyProvisioningProfileError, + MissingMacPasskeyDomainConfigurationError, + InvalidMacPasskeyPublishableKeyError, + MissingMacPasskeyRpDomainError, +]); +export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type; +export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError); + function normalizePasskeyRpDomain(value: string): string { const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + let parsed: URL; try { parsed = new URL(`https://${normalized}`); - } catch { - throw new Error(`Invalid passkey RP domain: ${value}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); } - if ( - normalized.length === 0 || - parsed.host !== normalized || - parsed.username.length > 0 || - parsed.password.length > 0 || - parsed.port.length > 0 || - parsed.pathname !== "/" || - parsed.search.length > 0 || - parsed.hash.length > 0 - ) { - throw new Error(`Invalid passkey RP domain: ${value}`); + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); } return parsed.hostname; @@ -336,14 +449,12 @@ export function resolveMacPasskeySigningConfiguration( ): MacPasskeySigningConfiguration { const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { - throw new Error("T3CODE_APPLE_TEAM_ID must be a 10-character Apple Developer Team ID."); + throw new InvalidAppleTeamIdError({ teamId }); } const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; if (provisioningProfilePath.length === 0) { - throw new Error( - "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", - ); + throw new MissingMacPasskeyProvisioningProfileError(); } const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim(); @@ -353,18 +464,20 @@ export function resolveMacPasskeySigningConfiguration( } else { const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim(); if (!publishableKey) { - throw new Error( - "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.", - ); + throw new MissingMacPasskeyDomainConfigurationError(); } - rpDomains = [ - normalizePasskeyRpDomain(clerkFrontendApiHostnameFromPublishableKey(publishableKey)), - ]; + let hostname: string; + try { + hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); + } catch (cause) { + throw new InvalidMacPasskeyPublishableKeyError({ cause }); + } + rpDomains = [normalizePasskeyRpDomain(hostname)]; } const uniqueRpDomains = [...new Set(rpDomains)]; if (uniqueRpDomains.length === 0) { - throw new Error("At least one Clerk passkey RP domain is required."); + throw new MissingMacPasskeyRpDomainError(); } return { @@ -1150,11 +1263,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform === "mac" && options.signed ? yield* Effect.try({ try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), - catch: (cause) => - new BuildScriptError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), + catch: BuildScriptError.fromMacPasskeySigningConfiguration, }) : undefined; const macPasskeySigning = configuredMacPasskeySigning From 515303edf2f83ab6ba15ebdae01ea2d5765756fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:41:55 -0700 Subject: [PATCH 072/142] [codex] Preserve desktop user-data probe failures (#3304) Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 35 ++++++++++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 28 ++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 7c4c06eb616..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"; @@ -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 2664581b187..385e694338d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,10 +18,22 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); +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, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -33,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; @@ -83,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); From b4fe8faa1d59aa602a04737aa4054ee8acb62193 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:42:38 -0700 Subject: [PATCH 073/142] [codex] Structure relay activity-row persistence errors (#3305) Co-authored-by: codex --- .../agentActivity/AgentActivityRows.test.ts | 84 ++++++++++++ .../src/agentActivity/AgentActivityRows.ts | 123 ++++++++++++------ .../agentActivity/MobileRegistrations.test.ts | 1 + 3 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 infra/relay/src/agentActivity/AgentActivityRows.test.ts diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts new file mode 100644 index 00000000000..be976d16bbb --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts @@ -0,0 +1,84 @@ +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; + +const state: RelayAgentActivityState = { + environmentId: "env-1" as RelayAgentActivityState["environmentId"], + threadId: "thread-1" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "2026-06-20T00:00:00.000Z", + deepLink: "/threads/env-1/thread-1", +}; + +describe("AgentActivityRows", () => { + it.effect("preserves activity context on persistence failures", () => { + const cause = new Error("database unavailable"); + const failingDb = { + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => Effect.fail(cause), + }), + }), + delete: () => ({ + where: () => Effect.fail(cause), + }), + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + orderBy: () => Effect.fail(cause), + }), + }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; + + const upsertError = yield* rows + .upsert({ environmentPublicKey: "public-key", state }) + .pipe(Effect.flip); + expect(upsertError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(upsertError.message).toBe( + "Failed to persist agent activity state for environment env-1, thread thread-1.", + ); + + const deleteError = yield* rows + .remove({ + environmentId: "env-1", + environmentPublicKey: "public-key", + threadId: "thread-1", + }) + .pipe(Effect.flip); + expect(deleteError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(deleteError.message).toBe( + "Failed to delete agent activity state for environment env-1, thread thread-1.", + ); + + const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip); + expect(listError).toMatchObject({ userId: "user-2", cause }); + expect(listError.message).toBe("Failed to list agent activity state for user user-2."); + }).pipe( + Effect.provide( + AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 7f19378633f..7e1a8c50f1b 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -14,28 +14,39 @@ import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/sc export class AgentActivityRowUpsertPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowUpsertPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist agent activity state"; + return `Failed to persist agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowDeletePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to delete agent activity state"; + return `Failed to delete agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list agent activity state"; + return `Failed to list agent activity state for user ${this.userId}.`; } } @@ -75,41 +86,56 @@ export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ - upsert: Effect.fn("relay.agent_activity_rows.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.state.environmentId, - "relay.thread_id": input.state.threadId, - }); - const now = yield* DateTime.now; - const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( - Effect.flatMap(decodeJsonString), - Effect.map(Function.cast), - ); - yield* db - .insert(relayAgentActivityRows) - .values({ - environmentId: input.state.environmentId, - environmentPublicKey: input.environmentPublicKey, - threadId: input.state.threadId, + upsert: Effect.fn("relay.agent_activity_rows.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.state.environmentId, + "relay.thread_id": input.state.threadId, + }); + const now = yield* DateTime.now; + const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(Function.cast), + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + yield* db + .insert(relayAgentActivityRows) + .values({ + environmentId: input.state.environmentId, + environmentPublicKey: input.environmentPublicKey, + threadId: input.state.threadId, + stateJson, + updatedAt: input.state.updatedAt, + createdAt: DateTime.formatIso(now), + }) + .onConflictDoUpdate({ + target: [ + relayAgentActivityRows.environmentId, + relayAgentActivityRows.environmentPublicKey, + relayAgentActivityRows.threadId, + ], + set: { stateJson, updatedAt: input.state.updatedAt, - createdAt: DateTime.formatIso(now), - }) - .onConflictDoUpdate({ - target: [ - relayAgentActivityRows.environmentId, - relayAgentActivityRows.environmentPublicKey, - relayAgentActivityRows.threadId, - ], - set: { - stateJson, - updatedAt: input.state.updatedAt, - }, - }); - }, - Effect.mapError((cause) => new AgentActivityRowUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -125,7 +151,16 @@ export const make = Effect.gen(function* () { eq(relayAgentActivityRows.threadId, input.threadId), ), ) - .pipe(Effect.mapError((cause) => new AgentActivityRowDeletePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowDeletePersistenceError({ + environmentId: input.environmentId, + threadId: input.threadId, + cause, + }), + ), + ); }), listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { @@ -159,7 +194,13 @@ export const make = Effect.gen(function* () { Effect.map((rows) => rows.flatMap((row) => Option.toArray(decodeRelayAgentActivityStateJson(row))), ), - Effect.mapError((cause) => new AgentActivityRowListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new AgentActivityRowListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index eed330dd589..17a9c7bd417 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -249,6 +249,7 @@ describe("MobileRegistrations", () => { replayForLiveActivityRegistration: () => Effect.fail( new AgentActivityRows.AgentActivityRowListPersistenceError({ + userId: "dev:julius", cause: "replay failed", }), ), From 8c3755aeccd5fc90fe66f6ca4d776c0e370fe46e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:54:12 -0700 Subject: [PATCH 074/142] [codex] Structure bootstrap errors (#3256) Co-authored-by: codex --- apps/server/src/bootstrap.test.ts | 107 ++++++++++++++++++++++++++++-- apps/server/src/bootstrap.ts | 98 +++++++++++++++++++++------ 2 files changed, 180 insertions(+), 25 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 84cf85c3213..05155f32ec4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ 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); + }, }; }); @@ -94,6 +111,39 @@ 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 = NodeFS.openSync("/dev/null", "r"); @@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + 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; diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 1114ad8af90..0f2a5a436a3 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -4,7 +4,6 @@ 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,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { + 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, }), ), @@ -98,24 +154,24 @@ const isFdReady = (fd: number) => Effect.try({ 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); } @@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), cause: error, }), }); From e55dd0067dde55770a4f7f7d1720615fcfd56389 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:54:56 -0700 Subject: [PATCH 075/142] [codex] Structure preview session key errors (#3388) Co-authored-by: codex --- .../src/components/preview/usePreviewSession.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index e5444bdd22d..2a82f627574 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -4,6 +4,7 @@ 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 * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { @@ -13,10 +14,19 @@ import { } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; +class PreviewSessionThreadKeyParseError extends Schema.TaggedErrorClass()( + "PreviewSessionThreadKeyParseError", + { threadKey: Schema.String }, +) { + override get message(): string { + return `Invalid scoped preview thread key: ${this.threadKey}`; + } +} + const previewSessionSyncAtom = Atom.family((threadKey: string) => { const threadRef = parseScopedThreadKey(threadKey); - if (!threadRef) { - throw new Error(`Invalid scoped preview thread key: ${threadKey}`); + if (threadRef === null) { + throw new PreviewSessionThreadKeyParseError({ threadKey }); } const sessionsAtom = previewEnvironment.list({ From d87ec967bf5ef884486479e887e74307276d3e74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:56:12 -0700 Subject: [PATCH 076/142] [codex] Structure empty mobile pairing payload errors (#3372) Co-authored-by: codex --- apps/mobile/src/features/connection/pairing.test.ts | 9 +++++++-- apps/mobile/src/features/connection/pairing.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) 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 { From 06752526b3354a560d647251676eb9d5c8a50e82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:57:33 -0700 Subject: [PATCH 077/142] [codex] Structure unavailable Bun PTY operations (#3394) Co-authored-by: codex --- apps/server/src/terminal/BunPtyAdapter.test.ts | 17 +++++++++++++++++ apps/server/src/terminal/BunPtyAdapter.ts | 17 +++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/terminal/BunPtyAdapter.test.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..39e811db3a9 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "@effect/vitest"; + +import { BunPtyOperationUnavailableError } from "./BunPtyAdapter.ts"; + +it("describes unavailable Bun PTY operations structurally", () => { + const error = new 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."); +}); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts index 045da058cf5..5d7a44a1071 100644 --- a/apps/server/src/terminal/BunPtyAdapter.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -2,10 +2,23 @@ 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 * as PtyAdapter from "./PtyAdapter.ts"; +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: PtyAdapter.PtyExitEvent) => void>(); @@ -33,14 +46,14 @@ class BunPtyProcess implements PtyAdapter.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); } From 7a8bab5d9df80e06782033d1ee252400e6f048b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:58:03 -0700 Subject: [PATCH 078/142] [codex] Keep PTY spawn errors structural (#3325) Co-authored-by: codex --- apps/server/src/terminal/PtyAdapter.test.ts | 34 +++++++++++++++++++++ apps/server/src/terminal/PtyAdapter.ts | 4 +-- 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/terminal/PtyAdapter.test.ts 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/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts index dafb6f12f4f..67147035bb5 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -25,9 +25,7 @@ export class PtySpawnError extends Schema.TaggedErrorClass()("Pty this.attemptedShells === undefined || this.attemptedShells.length === 0 ? "" : ` Tried shells: ${this.attemptedShells.join(", ")}.`; - const causeMessage = - this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : ""; - return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; } } From f7867addbe18ebb55c916041dc168aa4b8f39335 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:58:39 -0700 Subject: [PATCH 079/142] [codex] Structure mobile notification setting failures (#3391) Co-authored-by: codex --- .../liveActivityPreferences.ts | 15 +++++++++- .../notificationPermissions.ts | 29 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 8f73ffdf65e..932376e8bce 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; @@ -7,6 +8,18 @@ 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; @@ -15,7 +28,7 @@ export function setLiveActivityUpdatesEnabled(input: { 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" } From bf1a6501c7ada5822f0c68e8e5a0aa6bdbb9c1b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:59:10 -0700 Subject: [PATCH 080/142] [codex] Preserve review path resolution failures (#3357) Co-authored-by: codex --- apps/server/src/review/ReviewService.test.ts | 24 ++++++++++++++++++++ apps/server/src/review/ReviewService.ts | 20 ++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) 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 3f222bd520f..db1dc5bc8d2 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -33,8 +33,24 @@ export const make = Effect.gen(function* () { 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); From 71608142c27136a5ba0551fe9ed52ceeaad22ee4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:59:54 -0700 Subject: [PATCH 081/142] [codex] Split preferred editor precondition errors (#3324) Co-authored-by: codex --- apps/web/src/editorPreferences.ts | 45 ++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 32bdf42a807..d691ddb3153 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,11 +1,11 @@ -import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts"; +import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts"; import { mapAtomCommandResult, type AtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useCallback, useMemo } from "react"; @@ -14,11 +14,29 @@ import { useAtomCommand } from "./state/use-atom-command"; const LAST_EDITOR_KEY = "t3code:last-editor"; -export class PreferredEditorUnavailableError extends Data.TaggedError( +export class PreferredEditorEnvironmentRequiredError extends Schema.TaggedErrorClass()( + "PreferredEditorEnvironmentRequiredError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Cannot open ${this.targetPath} because no environment is selected.`; + } +} + +export class PreferredEditorUnavailableError extends Schema.TaggedErrorClass()( "PreferredEditorUnavailableError", -)<{ - readonly message: string; -}> {} + { + environmentId: EnvironmentId, + targetPath: Schema.String, + availableEditorIds: Schema.Array(EditorId), + }, +) { + override get message(): string { + return `No available editor can open ${this.targetPath} in environment ${this.environmentId}.`; + } +} export function usePreferredEditor(availableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); @@ -55,13 +73,18 @@ export function useOpenInPreferredEditor( async ( targetPath: string, ): Promise< - AtomCommandResult + AtomCommandResult< + EditorId, + | OpenInEditorError + | PreferredEditorEnvironmentRequiredError + | PreferredEditorUnavailableError + > > => { if (environmentId === null) { return AsyncResult.failure( Cause.fail( - new PreferredEditorUnavailableError({ - message: "No environment is selected.", + new PreferredEditorEnvironmentRequiredError({ + targetPath, }), ), ); @@ -71,7 +94,9 @@ export function useOpenInPreferredEditor( return AsyncResult.failure( Cause.fail( new PreferredEditorUnavailableError({ - message: "No available editors found.", + environmentId, + targetPath, + availableEditorIds: availableEditors, }), ), ); From cc69aef4dd140703762282ea207e0bcae7f9d9a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:00:42 -0700 Subject: [PATCH 082/142] [codex] Structure relay domain label errors (#3347) Co-authored-by: codex --- infra/relay/src/deploymentConfig.test.ts | 27 ++++++++++++++++++++++++ infra/relay/src/deploymentConfig.ts | 20 +++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index d7940b80318..44c7627a4da 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { managedEndpointDigestInput, @@ -7,11 +8,14 @@ import { isManagedEndpointHostname, managedEndpointTunnelName, relayOwnsManagedEndpointZone, + RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, relayStageSlug, } from "./deploymentConfig.ts"; +const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); + describe("relayStageSlug", () => { it("matches Alchemy physical-name sanitization for default developer stages", () => { expect(relayStageSlug("dev_julius")).toBe("dev-julius"); @@ -28,6 +32,29 @@ describe("relayPublicDomainForStage", () => { "relay-dev-julius.example.com", ); }); + + it("reports the stage and derived DNS label when the label is too long", () => { + const stage = `dev_${"x".repeat(60)}`; + let error: unknown; + + try { + relayPublicDomainForStage(stage, "example.com"); + } catch (cause) { + error = cause; + } + + if (!isRelayPublicDomainLabelTooLongError(error)) { + throw error; + } + expect(error).toMatchObject({ + stage, + label: `relay-dev-${"x".repeat(60)}`, + maxLength: 63, + }); + expect(error.message).toBe( + `Relay stage '${stage}' produces custom domain label 'relay-dev-${"x".repeat(60)}' (70 characters), exceeding the DNS label limit of 63.`, + ); + }); }); describe("relayOwnsManagedEndpointZone", () => { diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fbb13054822..fe9d37b2998 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -1,10 +1,24 @@ import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Schema from "effect/Schema"; const DNS_LABEL_MAX_LENGTH = 63; const MANAGED_ENDPOINT_HASH_LENGTH = 16; const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint"; export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod"; +export class RelayPublicDomainLabelTooLongError extends Schema.TaggedErrorClass()( + "RelayPublicDomainLabelTooLongError", + { + stage: Schema.String, + label: Schema.String, + maxLength: Schema.Number, + }, +) { + override get message(): string { + return `Relay stage '${this.stage}' produces custom domain label '${this.label}' (${this.label.length} characters), exceeding the DNS label limit of ${this.maxLength}.`; + } +} + function normalizeZoneName(zoneName: string): string { return zoneName .trim() @@ -62,7 +76,11 @@ export function relayPublicDomainForStage(stage: string, zoneName: string): stri const stageSlug = relayStageSlug(stage); const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; if (relayLabel.length > DNS_LABEL_MAX_LENGTH) { - throw new Error(`Relay stage is too long for a custom domain: ${stage}`); + throw new RelayPublicDomainLabelTooLongError({ + stage, + label: relayLabel, + maxLength: DNS_LABEL_MAX_LENGTH, + }); } return `${relayLabel}.${normalizeZoneName(zoneName)}`; } From 40d14647db0adfcb66b1bfe4482868c425c3c5d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:01:36 -0700 Subject: [PATCH 083/142] [codex] Structure catalog dependency resolution failures (#3298) Co-authored-by: codex --- scripts/lib/resolve-catalog.test.ts | 20 ++++++++++++++++++++ scripts/lib/resolve-catalog.ts | 27 +++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 scripts/lib/resolve-catalog.test.ts diff --git a/scripts/lib/resolve-catalog.test.ts b/scripts/lib/resolve-catalog.test.ts new file mode 100644 index 00000000000..ae9a2291157 --- /dev/null +++ b/scripts/lib/resolve-catalog.test.ts @@ -0,0 +1,20 @@ +import { assert, it } from "@effect/vitest"; + +import { CatalogDependencyResolutionError, resolveCatalogDependencies } from "./resolve-catalog.ts"; + +it("reports unresolved catalog dependencies with lookup context", () => { + try { + resolveCatalogDependencies({ effect: "catalog:runtime" }, {}, "apps/server"); + assert.fail("Expected catalog resolution to fail."); + } catch (error) { + assert.instanceOf(error, CatalogDependencyResolutionError); + assert.equal(error.workspacePackage, "apps/server"); + assert.equal(error.dependencyName, "effect"); + assert.equal(error.catalogSpec, "catalog:runtime"); + assert.equal(error.catalogKey, "runtime"); + assert.equal( + error.message, + "Unable to resolve 'catalog:runtime' for apps/server dependency 'effect'. Expected key 'runtime' in root workspace catalog.", + ); + } +}); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index 597bd06c24f..eb9d4cc78c8 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -1,3 +1,19 @@ +import * as Schema from "effect/Schema"; + +export class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( + "CatalogDependencyResolutionError", + { + workspacePackage: Schema.String, + dependencyName: Schema.String, + catalogSpec: Schema.String, + catalogKey: Schema.String, + }, +) { + override get message(): string { + return `Unable to resolve '${this.catalogSpec}' for ${this.workspacePackage} dependency '${this.dependencyName}'. Expected key '${this.catalogKey}' in root workspace catalog.`; + } +} + /** * Resolve `catalog:` dependency specs using the workspace catalog. * @@ -7,7 +23,7 @@ export function resolveCatalogDependencies( dependencies: Record, catalog: Record, - label: string, + workspacePackage: string, ): Record { return Object.fromEntries( Object.entries(dependencies).map(([name, spec]) => { @@ -20,9 +36,12 @@ export function resolveCatalogDependencies( const resolved = catalog[lookupKey]; if (typeof resolved !== "string" || resolved.length === 0) { - throw new Error( - `Unable to resolve '${spec}' for ${label} dependency '${name}'. Expected key '${lookupKey}' in root workspace catalog.`, - ); + throw new CatalogDependencyResolutionError({ + workspacePackage, + dependencyName: name, + catalogSpec: spec, + catalogKey: lookupKey, + }); } return [name, resolved]; From 08650a7424f0f4d28c36d50938c0027e5b4cafff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:02:07 -0700 Subject: [PATCH 084/142] [codex] Structure web diff worker failures (#3356) Co-authored-by: codex --- .../src/components/DiffWorkerPoolProvider.tsx | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) 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, }} From 8331511b972242301bdaec328148a88b9b6cfa6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:02:37 -0700 Subject: [PATCH 085/142] [codex] structure Electron theme source errors (#3294) Co-authored-by: codex --- .../src/electron/ElectronTheme.test.ts | 22 +++++++++++++++ apps/desktop/src/electron/ElectronTheme.ts | 27 +++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) 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 ef99a31067a..ef47e3d0954 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,16 +1,31 @@ -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 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 const isElectronThemeSetSourceError = Schema.is(ElectronThemeSetSourceError); + export class ElectronTheme extends Context.Service< ElectronTheme, { readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; readonly onUpdated: (listener: () => void) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronTheme") {} @@ -18,9 +33,11 @@ export class ElectronTheme extends Context.Service< 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( From f3b43a148b9e39192be5d43415289ac06eca1443 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:03:09 -0700 Subject: [PATCH 086/142] [codex] Structure missing client cloud config errors (#3346) Co-authored-by: codex --- .../mobile/src/features/cloud/publicConfig.test.ts | 13 ++++++++++++- apps/mobile/src/features/cloud/publicConfig.ts | 14 +++++++++++++- apps/web/src/cloud/publicConfig.test.ts | 14 +++++++++++++- apps/web/src/cloud/publicConfig.ts | 14 +++++++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 0307fcdab30..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 { hasTracingPublicConfig, 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: { diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 2d304da7c02..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: { @@ -87,7 +99,7 @@ export function hasTracingPublicConfig( 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/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); } From b9e22de6a354a97c2ecc772ad00754bfbd7333e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:04:53 -0700 Subject: [PATCH 087/142] [codex] Preserve desktop update state read failures (#3370) Co-authored-by: codex --- apps/web/src/state/desktopUpdate.test.ts | 22 +++++++++++++-- apps/web/src/state/desktopUpdate.ts | 35 ++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index 77409ef611d..f6b3081a80f 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -1,7 +1,7 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createDesktopUpdateStateAtom } from "./desktopUpdate"; @@ -22,6 +22,10 @@ const baseState: DesktopUpdateState = { canRetry: false, }; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("desktopUpdateStateAtom", () => { it("loads once, retains state, and follows desktop update events", async () => { let listener: ((state: DesktopUpdateState) => void) | undefined; @@ -91,8 +95,11 @@ describe("desktopUpdateStateAtom", () => { it("keeps listening when the initial desktop state read fails", async () => { let listener: ((state: DesktopUpdateState) => void) | undefined; + const cause = new Error("IPC unavailable"); + const reportError = vi.spyOn(console, "log").mockImplementation(() => undefined); + const getUpdateState = vi.fn(async () => Promise.reject(cause)); const atom = createDesktopUpdateStateAtom(() => ({ - getUpdateState: async () => Promise.reject(new Error("IPC unavailable")), + getUpdateState, onUpdateState: (nextListener) => { listener = nextListener; return () => undefined; @@ -102,6 +109,17 @@ describe("desktopUpdateStateAtom", () => { registry.mount(atom); await vi.waitFor(() => expect(listener).toBeDefined()); + await vi.waitFor(() => expect(reportError).toHaveBeenCalledOnce()); + expect(getUpdateState).toHaveBeenCalledTimes(3); + const [, errorMessage, errorContext] = reportError.mock.calls[0] ?? []; + expect(errorMessage).toBe("Failed to read the initial desktop update state after 3 attempts."); + expect(errorContext).toMatchObject({ + errorTag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + expect(errorContext).not.toHaveProperty("error"); + expect(errorContext).not.toHaveProperty("cause"); + listener?.(baseState); await vi.waitFor(() => { expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts index d08169770c3..75764410625 100644 --- a/apps/web/src/state/desktopUpdate.ts +++ b/apps/web/src/state/desktopUpdate.ts @@ -2,12 +2,27 @@ import { useAtomValue } from "@effect/atom-react"; import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Atom } from "effect/unstable/reactivity"; type DesktopUpdateBridge = Pick; +const INITIAL_STATE_READ_ATTEMPT_COUNT = 3; + +export class DesktopUpdateStateReadError extends Schema.TaggedErrorClass()( + "DesktopUpdateStateReadError", + { + attemptCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the initial desktop update state after ${this.attemptCount} attempts.`; + } +} + function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; } @@ -32,9 +47,23 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg (unsubscribe) => Effect.sync(unsubscribe), ); - const initialState = yield* Effect.tryPromise(() => bridge.getUpdateState()).pipe( - Effect.retry({ times: 2 }), - Effect.orElseSucceed(() => null), + const initialState = yield* Effect.tryPromise({ + try: () => bridge.getUpdateState(), + catch: (cause) => + new DesktopUpdateStateReadError({ + attemptCount: INITIAL_STATE_READ_ATTEMPT_COUNT, + cause, + }), + }).pipe( + Effect.retry({ times: INITIAL_STATE_READ_ATTEMPT_COUNT - 1 }), + Effect.catchTags({ + DesktopUpdateStateReadError: (error) => + Effect.logError(error.message, { + errorTag: error._tag, + attemptCount: error.attemptCount, + stack: error.stack, + }).pipe(Effect.as(null)), + }), ); if (!receivedUpdate && initialState !== null) { Queue.offerUnsafe(queue, initialState); From 8112aff7c2dc8a82447920e6fde232565149d32c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:05:24 -0700 Subject: [PATCH 088/142] [codex] Structure relay install confirmation conflicts (#3365) Co-authored-by: codex --- .../cloud/relayClientInstallDialog.test.ts | 25 ++++++++++++++ .../web/src/cloud/relayClientInstallDialog.ts | 34 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) 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 }); From 350e229f7a5d5c4cdc9a29cf2d1f911ef27a9a48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:05:40 -0700 Subject: [PATCH 089/142] [codex] Structure OAuth scope encoding failures (#3368) Co-authored-by: codex --- packages/shared/src/oauthScope.test.ts | 28 ++++++++++++++++++++- packages/shared/src/oauthScope.ts | 35 ++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/oauthScope.test.ts b/packages/shared/src/oauthScope.test.ts index 0aa4ef595a8..f5cc247a24a 100644 --- a/packages/shared/src/oauthScope.test.ts +++ b/packages/shared/src/oauthScope.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; -import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts"; +import { + encodeOAuthScope, + OAuthScopeEncodingError, + parseAllowedOAuthScope, + parseOAuthScope, +} from "./oauthScope.ts"; + +const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError); describe("OAuth scopes", () => { it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { @@ -32,4 +40,22 @@ describe("OAuth scopes", () => { }), ).toBeNull(); }); + + it("reports invalid encoding input structurally", () => { + expect.assertions(5); + + try { + encodeOAuthScope(["access:read", "invalid scope", "access:read"]); + } catch (error) { + expect(error).toBeInstanceOf(OAuthScopeEncodingError); + if (!isOAuthScopeEncodingError(error)) return; + + expect(error.scopes).toEqual(["access:read", "invalid scope", "access:read"]); + expect(error.invalidScopes).toEqual(["invalid scope"]); + expect(error.duplicateScopes).toEqual(["access:read"]); + expect(error.message).toBe( + "OAuth scopes must be non-empty, syntactically valid, and unique.", + ); + } + }); }); diff --git a/packages/shared/src/oauthScope.ts b/packages/shared/src/oauthScope.ts index 47c6dd7051b..4f427440660 100644 --- a/packages/shared/src/oauthScope.ts +++ b/packages/shared/src/oauthScope.ts @@ -1,5 +1,20 @@ +import * as Schema from "effect/Schema"; + const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; +export class OAuthScopeEncodingError extends Schema.TaggedErrorClass()( + "OAuthScopeEncodingError", + { + scopes: Schema.Array(Schema.String), + invalidScopes: Schema.Array(Schema.String), + duplicateScopes: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return "OAuth scopes must be non-empty, syntactically valid, and unique."; + } +} + /** * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen * order for canonical responses and logs. @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray | null { } export function encodeOAuthScope(scopes: ReadonlyArray): string { - const encoded = scopes.join(" "); - const parsed = parseOAuthScope(encoded); - if (parsed === null || parsed.length !== scopes.length) { - throw new Error("OAuth scopes must be non-empty, valid, and unique."); + const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope)); + const seen = new Set(); + const duplicateScopes = new Set(); + for (const scope of scopes) { + if (seen.has(scope)) duplicateScopes.add(scope); + seen.add(scope); + } + + if (scopes.length === 0 || invalidScopes.length > 0 || duplicateScopes.size > 0) { + throw new OAuthScopeEncodingError({ + scopes, + invalidScopes, + duplicateScopes: [...duplicateScopes], + }); } - return encoded; + return scopes.join(" "); } export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean { From 779c2374876884992c7cdf059556acd255db824e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:06:08 -0700 Subject: [PATCH 090/142] [codex] Structure native view resolution failures (#3353) Co-authored-by: codex --- .../diffs/nativeReviewDiffSurface.test.ts | 15 ++++++++++++++- .../features/diffs/nativeReviewDiffSurface.ts | 16 +++++++++++++++- .../terminal/nativeTerminalModule.test.ts | 15 ++++++++++++++- .../features/terminal/nativeTerminalModule.ts | 16 +++++++++++++++- .../src/native/nativeViewResolutionError.ts | 13 +++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/native/nativeViewResolutionError.ts 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 4a681663471..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 { @@ -128,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?.( @@ -140,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/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/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}.`; + } +} From 4fbc4f9b20cc4d2e991349ce30b0118473de4621 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:06:38 -0700 Subject: [PATCH 091/142] [codex] Structure mobile project thread validation errors (#3387) Co-authored-by: codex --- .../projectThreadCreationValidation.ts | 56 +++++++++++++++++++ .../features/threads/use-project-actions.ts | 20 ++++--- 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/projectThreadCreationValidation.ts 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/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index a0c19d9fe8b..9531567f447 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -21,6 +21,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; 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(); @@ -52,15 +53,16 @@ export function useCreateProjectThread() { const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); - if (initialMessageText.length === 0) { - const error = new Error("Enter a task before starting the thread."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); - } - if (input.envMode === "worktree" && !input.branch) { - const error = new Error("Select a base branch before creating a worktree."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); + 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"; From ac77fe452bc5c69acbbd05b55029b26ec40d3b43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:07:06 -0700 Subject: [PATCH 092/142] [codex] Preserve relay trace error causes (#3377) Co-authored-by: codex --- packages/shared/src/relayTracing.test.ts | 49 +++++++++++++++++++++++- packages/shared/src/relayTracing.ts | 29 +++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 10f4e1087a3..3bb7f1ea1ac 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; +import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; -import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts"; +import { + makeRelayClientTracingLayer, + RelayClientTracer, + withRelayClientTracing, +} from "./relayTracing.ts"; function collectingTracer(spans: Array): Tracer.Tracer { return Tracer.make({ @@ -54,4 +61,44 @@ describe("withRelayClientTracing", () => { expect(userSpans).toEqual(["relay.operation"]); }), ); + + it.effect("preserves nested error causes in exported relay spans", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const httpClientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), + ); + const tracingLayer = makeRelayClientTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "relay-traces", + tracesToken: "public-ingest-token", + }, + { + serviceName: "relay-test", + runtime: "test", + client: "test", + }, + ).pipe(Layer.provide(httpClientLayer)); + const rootCause = new Error("relay socket closed"); + const failure = new Error("relay request failed", { cause: rootCause }); + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("relay.failed-operation"), + withRelayClientTracing, + Effect.exit, + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); + expect(payload).toContain("relay request failed"); + expect(payload).toContain("relay socket closed"); + }), + ), + ); + }); }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index ecf035534ef..1259984ea3c 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -41,7 +41,16 @@ export const withRelayClientTracing = ( ), ); -function traceSafeError(value: unknown): Error { +function cleanTraceStack(error: Error): string { + const stack = error.stack ?? `${error.name}: ${error.message}`; + const lines = stack.split("\n"); + const effectFrameIndex = lines.findIndex( + (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line), + ); + return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); +} + +function traceSafeError(value: unknown, seen = new WeakSet()): Error { const message = value instanceof Error ? value.message @@ -51,12 +60,19 @@ function traceSafeError(value: unknown): Error { typeof value.message === "string" ? value.message : String(value); - const error = new Error(message); + + let cause: Error | undefined; + if (typeof value === "object" && value !== null && !seen.has(value)) { + seen.add(value); + if ("cause" in value && value.cause !== undefined) { + cause = traceSafeError(value.cause, seen); + } + } + + const error = new Error(message, cause ? { cause } : undefined); if (value instanceof Error) { error.name = value.name; - if (value.stack !== undefined) { - error.stack = value.stack; - } + error.stack = cleanTraceStack(value); } else if ( typeof value === "object" && value !== null && @@ -65,6 +81,9 @@ function traceSafeError(value: unknown): Error { ) { error.name = value.name; } + if (cause) { + error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; + } return error; } From 9d5ca2cb7ceec88e392d5dc3a70a5979953e4e3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:07:35 -0700 Subject: [PATCH 093/142] [codex] Structure Electron protocol teardown failures (#3310) Co-authored-by: codex --- .../src/electron/ElectronProtocol.test.ts | 55 +++++++++++++++++++ apps/desktop/src/electron/ElectronProtocol.ts | 25 +++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 619b7e871ab..56fe009fee2 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -98,6 +99,60 @@ describe("ElectronProtocol", () => { }).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.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", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 4c80c2c4900..757c26178d0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -31,7 +31,19 @@ export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolUnregistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister Electron protocol scheme "${this.scheme}".`; } } @@ -133,9 +145,14 @@ export const make = Effect.gen(function* () { catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), () => - Effect.sync(() => { - Electron.protocol.unhandle(input.scheme); - }).pipe(Effect.andThen(Ref.set(registered, false))), + 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), ); }, ); From 30a084c463acb7b8b75550a117b9bd82e98c5ac4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:08:03 -0700 Subject: [PATCH 094/142] [codex] Preserve mobile composer draft failures (#3348) Co-authored-by: codex --- apps/mobile/src/state/use-composer-drafts.ts | 81 ++++++++++++++++---- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index ab1fea9840d..d0329ad2598 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,5 +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"; @@ -11,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; @@ -56,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 {}; } @@ -75,30 +94,53 @@ async function loadPersistedComposerDrafts(): Promise): Promise { - const file = await getComposerDraftsFile(); - const nonEmptyDrafts = Object.fromEntries( - Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); - const document: PersistedComposerDrafts = { - schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, - drafts: nonEmptyDrafts, - }; - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + let operation: ComposerDraftPersistenceError["operation"] = "open"; + try { + const file = await getComposerDraftsFile(); + operation = "encode"; + const nonEmptyDrafts = Object.fromEntries( + Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); + const document: PersistedComposerDrafts = { + 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(encoded); + } catch (cause) { + throw new ComposerDraftPersistenceError({ + operation, + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, + }); } - file.write(JSON.stringify(document)); } async function savePersistedComposerDrafts(drafts: Record): Promise { try { await writePersistedComposerDrafts(drafts); - } catch { + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. } } @@ -128,7 +170,16 @@ export function ensureComposerDraftsLoaded(): void { ...current, }); }) - .catch(() => { + .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. }); } From fccecd8749056d4f811962a1523229500e7a4e72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:08:32 -0700 Subject: [PATCH 095/142] [codex] Preserve detached desktop action causes (#3371) Co-authored-by: codex --- .../app/DesktopDetachedActionErrors.test.ts | 37 +++++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 23 +++++++++--- .../src/window/DesktopApplicationMenu.ts | 24 ++++++++---- 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/app/DesktopDetachedActionErrors.test.ts 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/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ad08d2f5a2e..c5264332b66 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,8 +1,8 @@ -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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import type * as Electron from "electron"; @@ -15,6 +15,18 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +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 type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopShutdown.DesktopShutdown @@ -142,11 +154,10 @@ export const make = DesktopLifecycle.of({ }); yield* electronApp.exit(0); }).pipe( - Effect.catchCause((cause) => - logLifecycleError("desktop relaunch failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), Effect.forkDetach, Effect.asVoid, ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index cfe4f5702a1..a52707627b0 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,8 +1,8 @@ -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"; @@ -14,6 +14,18 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +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, { @@ -100,12 +112,10 @@ export 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 }); + }), ), ); }; From ce0c20b840f9e9d49532c3480113e1e54201fab0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:09:02 -0700 Subject: [PATCH 096/142] [codex] Structure desktop network interface failures (#3313) Co-authored-by: codex --- .../backend/DesktopNetworkInterfaces.test.ts | 65 +++++++++++++++++++ .../src/backend/DesktopNetworkInterfaces.ts | 27 ++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts 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 index 79b6b824c8a..43f634c4491 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -1,8 +1,10 @@ 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; @@ -18,6 +20,18 @@ 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, { @@ -25,9 +39,14 @@ export class DesktopNetworkInterfaces extends Context.Service< } >()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} -export const make = (): DesktopNetworkInterfaces["Service"] => - DesktopNetworkInterfaces.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), +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.succeed(DesktopNetworkInterfaces, make()); +export const layer = Layer.effect(DesktopNetworkInterfaces, make); From 04f82ae1f3c50f2c00bbb9c018c768b113b70ae7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:09:30 -0700 Subject: [PATCH 097/142] [codex] Structure relay environment link errors (#3334) Co-authored-by: codex --- .../environments/EnvironmentLinker.test.ts | 25 +++++++ .../src/environments/EnvironmentLinker.ts | 69 +++++++++++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 35dbd907dbe..f6bd1c6d977 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayTokens from "../auth/RelayTokens.ts"; @@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, }); +const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid); function signTestJwt(payload: object, typ: string, privateKey: string): string { const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); @@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } expect(persisted).toBe(false); }).pipe( Effect.provide( @@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "replayed_nonce", + stage: "consume_proof_nonce", + }); + } + } }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 9cb422bd317..6a97eefffa0 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass()( "EnvironmentLinkProofExpired", { + userId: Schema.String, + environmentId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment link proof expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`; } } export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass()( "EnvironmentLinkProofInvalid", { + userId: Schema.String, environmentId: Schema.String, reason: RelayEnvironmentLinkProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "decode_payload", + "verify_proof", + "authorize_capabilities", + "validate_descriptor", + "verify_challenge", + "validate_expiration", + "consume_proof_nonce", + "consume_challenge_nonce", + "validate_origin", + "validate_endpoint", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' link proof is invalid: ${this.reason}`; + return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`; } } @@ -132,20 +149,27 @@ const make = Effect.gen(function* () { const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); const unverified = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => + catch: (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: "unknown", reason: "invalid_signature_or_scope", + stage: "decode_token", + cause, }), }); - const decoded = yield* decodeProof(unverified).pipe(Effect.option); - if (decoded._tag === "None") { - return yield* new EnvironmentLinkProofInvalid({ - environmentId: "unknown", - reason: "invalid_signature_or_scope", - }); - } - const candidate = decoded.value; + const candidate = yield* decodeProof(unverified).pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkProofInvalid({ + userId: input.userId, + environmentId: "unknown", + reason: "invalid_signature_or_scope", + stage: "decode_payload", + cause, + }), + ), + ); yield* Effect.annotateCurrentSpan({ "relay.environment_id": candidate.environmentId, "relay.link.notifications_enabled": input.request.notificationsEnabled, @@ -154,6 +178,8 @@ const make = Effect.gen(function* () { }); if (candidate.exp <= nowSeconds) { return yield* new EnvironmentLinkProofExpired({ + userId: input.userId, + environmentId: candidate.environmentId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), }); } @@ -169,10 +195,13 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => + (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause, }), ), ); @@ -181,14 +210,18 @@ const make = Effect.gen(function* () { !proofAuthorizesRequestedCapabilities(verified, input.request) ) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "authorize_capabilities", }); } if (verified.descriptor.environmentId !== verified.environmentId) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "descriptor_mismatch", + stage: "validate_descriptor", }); } const challenge = yield* relayTokens.verifyLinkChallenge({ @@ -203,15 +236,19 @@ const make = Effect.gen(function* () { }); if (challenge === null) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "verify_challenge", }); } const expiresAt = DateTime.make(verified.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "invalid_signature_or_scope", + stage: "validate_expiration", }); } const consumedNonce = yield* proofReplay.consume({ @@ -222,8 +259,10 @@ const make = Effect.gen(function* () { }); if (!consumedNonce) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "replayed_nonce", + stage: "consume_proof_nonce", }); } const consumedChallenge = yield* proofReplay.consume({ @@ -234,14 +273,18 @@ const make = Effect.gen(function* () { }); if (!consumedChallenge) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "consume_challenge_nonce", }); } if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "origin_not_allowed", + stage: "validate_origin", }); } const provisioned = input.request.managedTunnelsEnabled @@ -254,8 +297,10 @@ const make = Effect.gen(function* () { const endpoint = provisioned?.endpoint ?? verified.endpoint; if (!isSecureManagedEndpoint(endpoint)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "endpoint_not_secure", + stage: "validate_endpoint", }); } yield* links.upsert({ ...input, proof: verified, endpoint }); From 61aade9ea4e7df15fd196d9805a00d24ed715f7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:10:16 -0700 Subject: [PATCH 098/142] [codex] Preserve desktop asset probe failures (#3373) Co-authored-by: codex --- apps/desktop/src/app/DesktopAssets.test.ts | 57 ++++++++++++++++++++++ apps/desktop/src/app/DesktopAssets.ts | 45 ++++++++++++++--- 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/DesktopAssets.test.ts 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 7591d6fd295..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,11 +13,26 @@ export interface DesktopIconPaths { readonly png: Option.Option; } +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< DesktopAssets, { readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: (fileName: string) => Effect.Effect>; + readonly resolveResourcePath: ( + fileName: string, + ) => Effect.Effect, DesktopAssetProbeError>; } >()("@t3tools/desktop/app/DesktopAssets") {} @@ -24,14 +40,20 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func 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); } @@ -43,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); } @@ -61,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 >(); From 5a2c92e86d16ecba823fdc61e163b8c35969f668 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:10:45 -0700 Subject: [PATCH 099/142] [codex] Structure Electron app boundary failures (#3301) Co-authored-by: codex --- apps/desktop/src/electron/ElectronApp.test.ts | 38 ++++++++++ apps/desktop/src/electron/ElectronApp.ts | 70 ++++++++++++++++--- 2 files changed, 98 insertions(+), 10 deletions(-) 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 3e894001e10..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,12 +14,36 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } +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 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 metadata: Effect.Effect; readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; + readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; @@ -63,15 +88,40 @@ const addScopedAppListener = >( ).pipe(Effect.asVoid); export 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, - })), + 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(); }), From d84ebe831922167d556e16cb40a238a904e0f14a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:11:15 -0700 Subject: [PATCH 100/142] [codex] Structure process resource sampling failures (#3415) Co-authored-by: codex --- .../ProcessResourceMonitor.test.ts | 33 +++++++++- .../src/diagnostics/ProcessResourceMonitor.ts | 66 ++++++++++++++----- packages/contracts/src/server.ts | 11 ++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index 49a9676ab11..d9c4eb06ef1 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -111,7 +111,7 @@ describe("ProcessResourceMonitor", () => { readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(Option.isNone(result.error)).toBe(true); @@ -171,7 +171,7 @@ describe("ProcessResourceMonitor", () => { readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(1); @@ -218,11 +218,38 @@ describe("ProcessResourceMonitor", () => { 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 b6e71dd2423..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,6 +12,7 @@ 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 ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -31,9 +34,21 @@ export interface ProcessResourceSample { readonly isServerRoot: boolean; } +export class ProcessResourceSamplingError extends Schema.TaggedErrorClass()( + "ProcessResourceSamplingError", + { + failureTag: ServerProcessResourceHistoryFailureTag, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sample process resources (${this.failureTag}).`; + } +} + interface MonitorState { readonly samples: ReadonlyArray; - readonly lastError: string | null; + readonly lastFailure: ProcessResourceSamplingError | null; } export class ProcessResourceMonitor extends Context.Service< @@ -218,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); @@ -239,13 +254,29 @@ 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.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; @@ -261,15 +292,16 @@ export const make = Effect.gen(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( @@ -287,7 +319,7 @@ export const make = Effect.gen(function* () { readAtMs, windowMs: input.windowMs, bucketMs: input.bucketMs, - lastError: current.lastError, + lastFailure: current.lastFailure, }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1aa280ad63b..b76ea965afe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -364,6 +364,16 @@ export const ServerProcessResourceHistorySummary = Schema.Struct({ }); export type ServerProcessResourceHistorySummary = typeof ServerProcessResourceHistorySummary.Type; +export const ServerProcessResourceHistoryFailureTag = Schema.Literals([ + "ProcessDiagnosticsQueryTimeoutError", + "ProcessDiagnosticsQueryFailedError", + "ProcessDiagnosticsServerProcessSignalError", + "ProcessDiagnosticsNotDescendantError", + "ProcessDiagnosticsSignalFailedError", +]); +export type ServerProcessResourceHistoryFailureTag = + typeof ServerProcessResourceHistoryFailureTag.Type; + export const ServerProcessResourceHistoryResult = Schema.Struct({ readAt: Schema.DateTimeUtc, windowMs: NonNegativeInt, @@ -375,6 +385,7 @@ export const ServerProcessResourceHistoryResult = Schema.Struct({ topProcesses: Schema.Array(ServerProcessResourceHistorySummary), error: Schema.Option( Schema.Struct({ + failureTag: ServerProcessResourceHistoryFailureTag, message: TrimmedNonEmptyString, }), ), From 53a477c2e91d206057adc5ac4c28cbdaddfbc07c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:11:43 -0700 Subject: [PATCH 101/142] [codex] Structure desktop backend settings read errors (#3379) Co-authored-by: codex --- .../DesktopBackendConfiguration.test.ts | 62 +++++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 51 ++++++++++----- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index cb68b2cd47f..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({ @@ -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 ec72faf910b..d8bd1a13dcb 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -8,12 +8,24 @@ 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 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, { @@ -50,25 +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.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; } From 300d4d566a1044dc401bcd7ebf43def08621229e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:12:02 -0700 Subject: [PATCH 102/142] [codex] Structure missing provider command failures (#3384) Co-authored-by: codex --- .../src/provider/providerSnapshot.test.ts | 78 ++++++++++++++++++- apps/server/src/provider/providerSnapshot.ts | 36 ++++++--- 2 files changed, 102 insertions(+), 12 deletions(-) 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); From 716ae73c40e988b462ab73f2bd7aaca789054e35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:12:31 -0700 Subject: [PATCH 103/142] [codex] Structure relay publish signature errors (#3335) Co-authored-by: codex --- .../EnvironmentPublishSignatures.test.ts | 58 +++++++++++++++++++ .../EnvironmentPublishSignatures.ts | 56 ++++++++++++++++-- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 2b19d4c9f1f..f61c5a27d5b 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -51,6 +52,9 @@ const state: RelayAgentActivityState = { updatedAt: "2026-05-25T00:00:00.000Z", deepLink: "/threads/env/thread", }; +const isEnvironmentPublishSignatureInvalid = Schema.is( + EnvironmentPublishSignatures.EnvironmentPublishSignatureInvalid, +); function signTestJwt(payload: object, privateKey: string): string { const header = Buffer.from( @@ -145,6 +149,49 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", + }); + } + } + }).pipe(Effect.provide(layer())), + ); + + it.effect("preserves the JWT verification failure", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const segments = request.proof.split("."); + const signature = segments[2]!; + segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, proof: segments.join(".") }, + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } }).pipe(Effect.provide(layer())), ); @@ -161,6 +208,17 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", + }); + } + } }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index ffc8c124b7b..eb9c15a75aa 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -1,5 +1,6 @@ import { RelayAgentActivityPublishProofPayload, + RelayAgentActivityPublishProofInvalidReason, type RelayAgentActivityPublishRequest, } from "@t3tools/contracts/relay"; import { @@ -23,11 +24,13 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentPublishSignatureExpired extends Schema.TaggedErrorClass()( "EnvironmentPublishSignatureExpired", { + environmentId: Schema.String, + threadId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment publish signature expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' expired at ${this.expiresAt}`; } } @@ -35,10 +38,21 @@ export class EnvironmentPublishSignatureInvalid extends Schema.TaggedErrorClass< "EnvironmentPublishSignatureInvalid", { environmentId: Schema.String, + threadId: Schema.String, + reason: RelayAgentActivityPublishProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "verify_proof", + "validate_claims", + "validate_expiration", + "generate_replay_thumbprint", + "consume_nonce", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' publish signature is invalid`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' is invalid during ${this.stage}: ${this.reason}`; } } @@ -102,13 +116,22 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const decoded = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + catch: (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "decode_token", + cause, + }), }); if ( typeof decoded.exp === "number" && decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) ) { return yield* new EnvironmentPublishSignatureExpired({ + environmentId: input.environmentId, + threadId: input.threadId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), }); } @@ -122,7 +145,14 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause, + }), ), ); if ( @@ -136,12 +166,18 @@ const make = Effect.gen(function* () { ) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", }); } const expiresAt = DateTime.make(proof.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_expiration", }); } const thumbprint = yield* crypto @@ -155,7 +191,14 @@ const make = Effect.gen(function* () { .pipe( Effect.map(formatEnvironmentPublishReplayThumbprint), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "generate_replay_thumbprint", + cause, + }), ), ); const consumedNonce = yield* proofReplay.consume({ @@ -167,6 +210,9 @@ const make = Effect.gen(function* () { if (!consumedNonce) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", }); } }), From 3ecf3685ba7dbaca04f6a1219a8d42e6094dc526 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:13:02 -0700 Subject: [PATCH 104/142] [codex] structure Bitbucket API failures (#3332) Co-authored-by: codex --- .../src/sourceControl/BitbucketApi.test.ts | 97 ++++++++++++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 19 ++-- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5041fe6635b..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,10 +59,15 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + 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(() => @@ -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 43a1a705e67..9a678ab44dc 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -338,14 +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 responseError( operation: string, response: HttpClientResponse.HttpClientResponse, @@ -412,7 +404,14 @@ export const make = Effect.gen(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)), ); @@ -746,7 +745,7 @@ export const make = Effect.gen(function* () { ? cause : new BitbucketApiError({ operation: "checkoutPullRequest", - detail: cause instanceof Error ? cause.message : String(cause), + detail: "Failed to check out the Bitbucket pull request.", cause, }), ), From 4d790f0064ab99287d3ccb17d2f003f5ed8e8d3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:13:27 -0700 Subject: [PATCH 105/142] [codex] Bound relay registration replay diagnostics (#3420) Co-authored-by: codex --- .../src/agentActivity/MobileRegistrations.test.ts | 14 ++++++++++++-- .../relay/src/agentActivity/MobileRegistrations.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 17a9c7bd417..a223e9707c4 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -7,6 +7,7 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; import { FetchHttpClient } from "effect/unstable/http"; @@ -232,6 +233,11 @@ describe("MobileRegistrations", () => { }); it.effect("keeps device registration successful when activity replay fails", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { const result = yield* Effect.gen(function* () { const registrations = yield* MobileRegistrations.MobileRegistrations; @@ -250,7 +256,7 @@ describe("MobileRegistrations", () => { Effect.fail( new AgentActivityRows.AgentActivityRowListPersistenceError({ userId: "dev:julius", - cause: "replay failed", + cause: "sensitive device replay detail", }), ), }), @@ -262,7 +268,11 @@ describe("MobileRegistrations", () => { ); expect(result).toEqual({ ok: true }); - }); + expect(messages).toContainEqual([ + "device registration activity replay failed", + { errorTag: "AgentActivityRowListPersistenceError" }, + ]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); it.effect("unregisters the current user's device", () => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 395422b81dd..0df0379cded 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -51,8 +51,10 @@ export const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("device registration activity replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("device registration activity replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); @@ -70,8 +72,10 @@ export const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("live activity registration replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("live activity registration replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); From 4d3fcacd840d7b710f8415b4bebf3826df358c91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:18:38 -0700 Subject: [PATCH 106/142] [codex] Type malformed Clerk public config failures (#3422) Co-authored-by: codex --- apps/server/src/cloud/publicConfig.test.ts | 22 ++++++++++++++ apps/server/src/cloud/publicConfig.ts | 34 +++++++++++++++------- 2 files changed, 46 insertions(+), 10 deletions(-) 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, + ), + ), + ), ); } From bfe61741b83dd7f2f66af7e7570ae0f67a16d631 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:11 -0700 Subject: [PATCH 107/142] [codex] Simplify desktop client settings errors (#3265) Co-authored-by: codex --- .../settings/DesktopClientSettings.test.ts | 5 +- .../src/settings/DesktopClientSettings.ts | 71 +++++++++++++------ 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 2d1d7fc547d..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"; @@ -117,11 +118,13 @@ describe("DesktopClientSettings", () => { assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); assert.equal(error.operation, "replace-settings-file"); assert.equal(error.path, environment.clientSettingsPath); - assert.exists(error.cause); + 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); }), ), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 585397d7502..d08184f4ab7 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -25,7 +25,9 @@ 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); @@ -36,7 +38,6 @@ const DesktopClientSettingsWriteOperation = Schema.Literals([ "write-temporary-file", "replace-settings-file", ]); -type DesktopClientSettingsWriteOperation = typeof DesktopClientSettingsWriteOperation.Type; export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass()( "DesktopClientSettingsWriteError", @@ -51,13 +52,6 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass - new DesktopClientSettingsWriteError({ operation, path, cause }); - export class DesktopClientSettings extends Context.Service< DesktopClientSettings, { @@ -96,19 +90,45 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeClientSettingsJson(input.settings).pipe( - Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)), + 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, + }), + ), ); - 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 make = Effect.gen(function* () { @@ -124,8 +144,13 @@ export const make = Effect.gen(function* () { set: (settings) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.clientSettingsPath, cause), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.clientSettingsPath, + cause, + }), ), Effect.flatMap((suffix) => writeClientSettings({ From f98448e8721a491f5945f1857cbd22fa87a955ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:39 -0700 Subject: [PATCH 108/142] [codex] Structure relay JWT failures (#3270) Co-authored-by: codex --- infra/relay/src/auth/RelayTokens.ts | 15 +------ packages/shared/src/relayJwt.test.ts | 58 ++++++++++++++++++++++++++++ packages/shared/src/relayJwt.ts | 41 +++++++++++++++++--- 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 packages/shared/src/relayJwt.test.ts diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index 6c726ffa826..bf48980907a 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -11,9 +11,9 @@ import { import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { normalizeRelayIssuer, + RelayJwtError, signRelayJwt, verifyRelayJwt, - type RelayJwtError, } from "@t3tools/shared/relayJwt"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -72,17 +72,6 @@ const allowedScopesByClientId: Record< [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), }; -function relayJwtVerificationFailureReason(error: RelayJwtError): string { - const cause = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - const code = (cause as { readonly code?: unknown }).code; - if (typeof code === "string" && code.length > 0) { - return code; - } - } - return cause instanceof Error && cause.name ? cause.name : "unknown"; -} - function resolveDpopAccessTokenScopes(input: { readonly clientId: RelayPublicClientId; readonly scope: string; @@ -211,7 +200,7 @@ const make = Effect.gen(function* () { Effect.tapError((error) => Effect.annotateCurrentSpan( "relay.tokens.verification_failure", - relayJwtVerificationFailureReason(error), + RelayJwtError.diagnosticCode(error), ), ), Effect.flatMap(decodeDpopAccessTokenClaims), diff --git a/packages/shared/src/relayJwt.test.ts b/packages/shared/src/relayJwt.test.ts new file mode 100644 index 00000000000..4e863af484e --- /dev/null +++ b/packages/shared/src/relayJwt.test.ts @@ -0,0 +1,58 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { RelayJwtError, signRelayJwt, verifyRelayJwt } from "./relayJwt.ts"; + +describe("relayJwt", () => { + it.effect("preserves signing context and the JOSE cause", () => + Effect.gen(function* () { + const error = yield* signRelayJwt({ + privateKey: "not-a-private-key", + typ: "test-sign+jwt", + payload: { sub: "subject" }, + }).pipe(Effect.flip); + + expect(error.operation).toBe("sign"); + expect(error.typ).toBe("test-sign+jwt"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to sign relay JWT of type "test-sign+jwt".'); + }), + ); + + it.effect("preserves verification request context and the JOSE cause", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { format: "pem", type: "spki" }, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + }); + const error = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: "not-a-jwt", + typ: "test-verify+jwt", + issuer: "https://issuer.example.test", + audience: "test-audience", + nowEpochSeconds: 100, + }).pipe(Effect.flip); + + expect(error.operation).toBe("verify"); + expect(error.typ).toBe("test-verify+jwt"); + expect(error.issuer).toBe("https://issuer.example.test"); + expect(error.audience).toBe("test-audience"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to verify relay JWT of type "test-verify+jwt".'); + }), + ); + + it("extracts stable diagnostic codes without copying cause text into the error message", () => { + const error = new RelayJwtError({ + operation: "verify", + typ: "test+jwt", + cause: { code: "ERR_JWT_EXPIRED", message: "sensitive library detail" }, + }); + + expect(RelayJwtError.diagnosticCode(error)).toBe("ERR_JWT_EXPIRED"); + expect(error.message).not.toContain("sensitive library detail"); + }); +}); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index 20d55a530e3..9e848bedfb0 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -1,7 +1,8 @@ import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; @@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; -export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ - readonly cause: unknown; -}> {} +export class RelayJwtError extends Schema.TaggedErrorClass()("RelayJwtError", { + operation: Schema.Literals(["sign", "verify"]), + typ: Schema.String, + issuer: Schema.optional(Schema.String), + audience: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} relay JWT of type "${this.typ}".`; + } + + static diagnosticCode(error: RelayJwtError): string { + if ( + Predicate.isObject(error.cause) && + Predicate.hasProperty(error.cause, "code") && + Predicate.isString(error.cause.code) && + error.cause.code.length > 0 + ) { + return error.cause.code; + } + + return error.cause instanceof Error && error.cause.name ? error.cause.name : "unknown"; + } +} export function normalizeRelayIssuer(value: string): string { return value.trim().replace(/\/+$/gu, ""); @@ -38,7 +60,7 @@ export function signRelayJwt(input: { .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) .sign(key); }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }), }); } @@ -65,6 +87,13 @@ export function verifyRelayJwt(input: { }); return verified.payload; }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => + new RelayJwtError({ + operation: "verify", + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + cause, + }), }); } From ed6ba7439d55b8c2cf7324d5107c2c1cb3ee0920 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:57 -0700 Subject: [PATCH 109/142] [codex] sanitize provider runtime failure diagnostics (#3414) Co-authored-by: codex --- .../src/provider/Layers/ClaudeProvider.ts | 16 +++++---- .../src/provider/Layers/CursorProvider.ts | 11 +++--- .../src/provider/Layers/GrokProvider.test.ts | 6 ++-- .../src/provider/Layers/GrokProvider.ts | 27 +++++++++------ .../provider/Layers/ProviderRegistry.test.ts | 13 ++++--- .../src/provider/Layers/ProviderService.ts | 6 ++-- .../src/provider/providerStatusCache.test.ts | 34 +++++++++++++++++++ .../src/provider/providerStatusCache.ts | 4 +-- 8 files changed, 85 insertions(+), 32 deletions(-) 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/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 94faac60647..ff96ece9349 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -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"; @@ -1006,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, @@ -1018,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.", }, }); } @@ -1074,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)) { @@ -1130,7 +1133,7 @@ export const enrichCursorSnapshot = (input: { ), 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/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 b1c84fb3a03..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.", }, }); } @@ -324,7 +329,7 @@ export const enrichGrokSnapshot = (input: { 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/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1805b6ed277..b3ab1145495 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1875,14 +1875,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }).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) => { @@ -1890,14 +1893,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te 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.ts b/apps/server/src/provider/Layers/ProviderService.ts index c15d50eed62..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"; @@ -1061,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), + }), ), ), ); 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, }), From 1e5f62801b7c4d247247bc3648310a20560e5b43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:20:14 -0700 Subject: [PATCH 110/142] [codex] Structure MCP snapshot failures (#3423) Co-authored-by: codex --- apps/server/src/mcp/McpHttpServer.test.ts | 56 +++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 65 ++++++++++++++++------- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 210bb7e5ad8..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* () { 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, + }, + ], + }), + ); }, }), ); From 1486a4a2b9c3f5a83d54f5ce591a2f60fba9316f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:20:46 -0700 Subject: [PATCH 111/142] [codex] Structure Electron updater errors (#3280) Co-authored-by: codex --- .../src/electron/ElectronUpdater.test.ts | 66 ++++++++++++++++--- apps/desktop/src/electron/ElectronUpdater.ts | 58 ++++++++++------ 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 43a3c84dcd4..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,16 +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.equal(error.message, "Electron updater failed to check for updates."); - } + 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 8a468a15c20..435fbd00228 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -10,40 +10,41 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -const electronUpdaterErrorFields = { - cause: Schema.Defect(), -}; - export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", { - ...electronUpdaterErrorFields, + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return "Electron updater failed to check for updates."; + return `Electron updater failed to check for updates on channel ${this.channel ?? "default"}.`; } } export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", { - ...electronUpdaterErrorFields, + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return "Electron updater failed to download the update."; + return `Electron updater failed to download the update on channel ${this.channel ?? "default"}.`; } } export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", { - ...electronUpdaterErrorFields, + 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."; + return `Electron updater failed to quit and install the update on channel ${this.channel ?? "default"} (silent: ${this.isSilent}, force run after: ${this.isForceRunAfter}).`; } } @@ -116,18 +117,33 @@ export const make = ElectronUpdater.of({ 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 { From c3e3e26844a591f2bb21e617db3c5494e700fbcc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:22:28 -0700 Subject: [PATCH 112/142] [codex] Structure primary environment request failures (#3409) Co-authored-by: codex --- apps/web/src/authBootstrap.test.ts | 38 +++-- apps/web/src/environments/primary/auth.ts | 154 +++++++++++-------- apps/web/src/environments/primary/context.ts | 14 +- apps/web/src/environments/primary/index.ts | 2 + 4 files changed, 127 insertions(+), 81 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 53c17c06402..c0713bfc059 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -290,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/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..5cf7d2d34b7 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,57 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + detail: Schema.String, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly fallbackMessage: (status: number) => string; + readonly formatDetail?: (detail: string, status: number) => string; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status)); + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + detail: input.formatDetail?.(rawDetail, status) ?? rawDetail, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return this.detail; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, + formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, + }); } } @@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, + }); } } @@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, + }); } } @@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to load paired clients (${status}).`, + }); } } @@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + fallbackMessage: (status) => `Failed to revoke client session (${status}).`, + }); } } @@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`, + }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index eb818e8f558..40b6f68bd09 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -5,9 +5,8 @@ import { } from "@t3tools/client-runtime/environment"; import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; @@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, + fallbackMessage: (status) => `Failed to load server environment descriptor (${status}).`, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 305ced9c905..3cb570d66ab 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,9 +16,11 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession, From 5ca7676661325b13ee2a557b28f0d0d3ce8ab695 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:22:57 -0700 Subject: [PATCH 113/142] [codex] Structure Claude adapter failures (#3406) Co-authored-by: codex --- .../src/provider/Layers/ClaudeAdapter.test.ts | 116 +++++++++++++++--- .../src/provider/Layers/ClaudeAdapter.ts | 95 +++++++------- 2 files changed, 143 insertions(+), 68 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4d22a2c1f8d..191bf8e27db 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -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* () { @@ -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, }), }); From 49c23221d80c68114fe4d88e92b05fa1129de0d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:23:58 -0700 Subject: [PATCH 114/142] [codex] Structure mobile secure storage failures (#3345) Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 31 ++++++++++ apps/mobile/src/lib/storage.ts | 95 +++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index c3dd28ac3a1..084f9430d08 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -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 da54f92949b..114648277b9 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,5 +1,6 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; import { EnvironmentId } from "@t3tools/contracts"; @@ -12,21 +13,72 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +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 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}.`; + } +} + +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}.`; + } +} export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } -async function readStorageItem(key: string): Promise { - return await SecureStore.getItemAsync(key); +async function readStorageItem(key: MobileStorageKeyValue): Promise { + try { + return await SecureStore.getItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "read", key, cause }); + } } -async function writeStorageItem(key: string, value: string): Promise { - await SecureStore.setItemAsync(key, value); +async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { + try { + await SecureStore.setItemAsync(key, value); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "write", key, cause }); + } } -async function readJsonStorageItem(key: string): Promise { +async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { const raw = (await readStorageItem(key)) ?? ""; if (!raw.trim()) { return null; @@ -34,11 +86,25 @@ async function readJsonStorageItem(key: string): Promise { try { return JSON.parse(raw) as T; - } catch { + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); return null; } } +async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { + let encoded: string; + try { + encoded = JSON.stringify(value); + } catch (cause) { + throw new MobileStorageEncodeError({ key, cause }); + } + await writeStorageItem(key, encoded); +} + export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; @@ -67,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 { @@ -76,7 +142,7 @@ 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 { @@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial): Promise ...current, ...patch, }; - await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } @@ -116,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; } From 2b8e012924063e185786c8ff120b6fd2886b3aa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:24:35 -0700 Subject: [PATCH 115/142] [codex] Structure desktop server exposure errors (#3269) Co-authored-by: codex --- .../src/backend/DesktopServerExposure.test.ts | 66 ++++++++++++++++++- .../src/backend/DesktopServerExposure.ts | 60 ++++++++++++----- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 6bfe2e097ae..8b934fd8d85 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -91,6 +91,7 @@ function makeLayer(input: { readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; readonly env?: Record; readonly spawnerLayer?: Layer.Layer; + readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); @@ -99,7 +100,7 @@ function makeLayer(input: { }); 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()), @@ -122,6 +123,7 @@ const withHarness = ( >, env: Record = {}, spawnerLayer?: Layer.Layer, + desktopSettingsLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -135,6 +137,7 @@ const withHarness = ( networkInterfaces, env, ...(spawnerLayer ? { spawnerLayer } : {}), + ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), ); @@ -237,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 64e65a61c77..f04d2af7b1f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -2,11 +2,12 @@ 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"; @@ -213,23 +214,45 @@ export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErr } } -export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass()( - "DesktopServerExposurePersistenceError", +export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass()( + "DesktopServerExposureModePersistenceError", { - operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]), + mode: DesktopServerExposureModeSchema, cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), }, ) { override get message(): string { - return `Failed to persist desktop ${this.operation} settings.`; + 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; @@ -258,7 +281,7 @@ export class DesktopServerExposure extends Context.Service< readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} @@ -449,8 +472,8 @@ export 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, }), ), @@ -477,8 +500,9 @@ export const make = Effect.gen(function* () { .pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", + new DesktopTailscaleServePersistenceError({ + enabled: input.enabled, + port: input.port ?? null, cause, }), ), From 2910d9ff0106d06f8fc1c55a44787385118f595a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:25:14 -0700 Subject: [PATCH 116/142] [codex] Structure preview config failures (#3271) Co-authored-by: codex --- .../browser/previewWebviewConfigState.test.ts | 58 +++++++++++++++ .../src/browser/previewWebviewConfigState.ts | 70 +++++++++++++------ 2 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/browser/previewWebviewConfigState.test.ts 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, From 48f88d522047198a660cacebbde854a617068720 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:26:03 -0700 Subject: [PATCH 117/142] [codex] Structure desktop Clerk bridge failures (#3308) Co-authored-by: codex --- apps/desktop/src/app/DesktopClerk.test.ts | 79 +++++++++++++++++++---- apps/desktop/src/app/DesktopClerk.ts | 50 +++++++++++++- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a80a9fe24fb..9b5ed56d1f3 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,7 +1,8 @@ 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 { vi } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ createClerkBridgeMock: vi.fn(), @@ -24,7 +25,23 @@ vi.mock("@clerk/electron/storage", () => ({ 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$")}`; @@ -40,19 +57,9 @@ describe("DesktopClerk", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup }); - const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", - isDevelopment: true, - } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return Effect.gen(function* () { - yield* Effect.scoped( - Layer.build( - DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), - ), - ), - ); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -69,6 +76,54 @@ describe("DesktopClerk", () => { }); }); + 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" }, diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 1fa5640b2ee..0e283f8dd0c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -4,6 +4,7 @@ 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"; @@ -14,6 +15,32 @@ 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, { @@ -55,11 +82,28 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea }); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* Effect.acquireRelease( - Effect.sync(() => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment)), - (bridge) => Effect.sync(() => bridge.cleanup()), + 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({ From 9c98cd60e866fb576e7029cd1152f75aca06452c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:26:51 -0700 Subject: [PATCH 118/142] Remove persistence error constructor wrappers (#3398) Co-authored-by: codex --- .../src/persistence/AuthPairingLinks.ts | 8 +-- apps/server/src/persistence/AuthSessions.ts | 8 +-- apps/server/src/persistence/Errors.test.ts | 49 +++++++++++++++++++ apps/server/src/persistence/Errors.ts | 47 +++++++++++------- .../src/persistence/ProviderSessionRuntime.ts | 26 ++++++---- 5 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 apps/server/src/persistence/Errors.test.ts diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index add90f04803..c29b023d1d8 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -10,8 +10,8 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + PersistenceSqlError, } from "./Errors.ts"; export const AuthPairingLinkRecord = Schema.Struct({ @@ -90,8 +90,8 @@ export class AuthPairingLinkRepository extends Context.Service< 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 }); } export const make = Effect.gen(function* () { diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index e3e8a19f5d0..17f76042d0a 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -15,8 +15,8 @@ import { import { type AuthSessionRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + PersistenceSqlError, } from "./Errors.ts"; export const AuthSessionClientMetadataRecord = Schema.Struct({ @@ -146,8 +146,8 @@ 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 }); } export const make = Effect.gen(function* () { 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/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 6bbbfbd4e19..af48efdb50e 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -16,9 +16,9 @@ import { } from "@t3tools/contracts"; import { + PersistenceDecodeError, + PersistenceSqlError, type ProviderSessionRuntimeRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, } from "./Errors.ts"; /** @@ -117,8 +117,8 @@ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProviderSessionRuntimeRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } export const make = Effect.gen(function* () { @@ -235,9 +235,10 @@ export const make = Effect.gen(function* () { onNone: () => Effect.succeed(Option.none()), onSome: (row) => decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + cause, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -259,8 +260,11 @@ export const make = Effect.gen(function* () { rows, (row) => decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:rowToRuntime", + cause, + ), ), ), { concurrency: "unbounded" }, @@ -273,7 +277,11 @@ export const make = Effect.gen(function* () { ) => deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + cause, + }), ), ); From ee3e2dae7880c2f4f0d43e04305438e083f13650 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:27:39 -0700 Subject: [PATCH 119/142] [codex] Structure remote pairing input errors (#3393) Co-authored-by: codex --- packages/shared/src/remote.test.ts | 46 ++++++++++++++- packages/shared/src/remote.ts | 93 +++++++++++++++++++++++++++--- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 5ed058b9dc5..54c78907421 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveRemotePairingTarget } from "./remote.ts"; +import { + RemoteBackendUrlInvalidError, + RemoteBackendUrlMissingError, + RemotePairingTokenMissingError, + RemotePairingUrlInvalidError, + resolveRemotePairingTarget, +} from "./remote.ts"; describe("remote", () => { it("derives backend urls and token from a pairing url", () => { @@ -65,4 +71,42 @@ describe("remote", () => { wsBaseUrl: "wss://myserver.com:3000/", }); }); + + it("uses distinct structural errors for missing pairing inputs", () => { + expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); + expect(() => + resolveRemotePairingTarget({ pairingUrl: "https://remote.example.com/pair" }), + ).toThrowError(RemotePairingTokenMissingError); + expect(() => + resolveRemotePairingTarget({ + host: "https://user:secret@remote.example.com/path?token=sensitive#fragment", + }), + ).toThrowError( + expect.objectContaining({ + _tag: "RemotePairingCodeMissingError", + host: "remote.example.com", + }), + ); + }); + + it("preserves URL parsing causes with their input source", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ pairingUrl: "not a url" }); + } catch (cause) { + pairingUrlError = cause; + } + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeInstanceOf(TypeError); + + let hostError: unknown; + try { + resolveRemotePairingTarget({ host: "https://[invalid", pairingCode: "pairing-token" }); + } catch (cause) { + hostError = cause; + } + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeInstanceOf(TypeError); + }); }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index c2d6079680d..703811609b8 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,3 +1,5 @@ +import * as Schema from "effect/Schema"; + const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; @@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label"; const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); -const normalizeRemoteBaseUrl = (rawValue: string): URL => { +export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlMissingError", + {}, +) { + override get message(): string { + return "Enter a backend URL."; + } +} + +export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass()( + "RemotePairingUrlInvalidError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Pairing URL is invalid."; + } +} + +export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlInvalidError", + { + source: Schema.Literals(["direct-host", "hosted-pairing-host"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Backend URL is invalid."; + } +} + +export class RemotePairingTokenMissingError extends Schema.TaggedErrorClass()( + "RemotePairingTokenMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Pairing URL is missing its token."; + } +} + +export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass()( + "RemotePairingCodeMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Enter a pairing code."; + } +} + +export const RemotePairingTargetError = Schema.Union([ + RemoteBackendUrlMissingError, + RemotePairingUrlInvalidError, + RemoteBackendUrlInvalidError, + RemotePairingTokenMissingError, + RemotePairingCodeMissingError, +]); +export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; + +const normalizeRemoteBaseUrl = ( + rawValue: string, + source: RemoteBackendUrlInvalidError["source"], +): URL => { const trimmed = rawValue.trim(); if (!trimmed) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") ? trimmed : `https://${trimmed}`; - const url = new URL(normalizedInput); + let url: URL; + try { + url = new URL(normalizedInput); + } catch (cause) { + throw new RemoteBackendUrlInvalidError({ source, cause }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: { }): ResolvedRemotePairingTarget => { const pairingUrl = input.pairingUrl?.trim() ?? ""; if (pairingUrl.length > 0) { - const url = new URL(pairingUrl); + let url: URL; + try { + url = new URL(pairingUrl); + } catch (cause) { + throw new RemotePairingUrlInvalidError({ cause }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { - const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); + const hostedBackendUrl = normalizeRemoteBaseUrl( + hostedPairingRequest.host, + "hosted-pairing-host", + ); return { credential: hostedPairingRequest.token, httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), @@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: { const credential = getPairingTokenFromUrl(url) ?? ""; if (!credential) { - throw new Error("Pairing URL is missing its token."); + throw new RemotePairingTokenMissingError({ host: url.host }); } return { credential, @@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: { const host = input.host?.trim() ?? ""; const pairingCode = input.pairingCode?.trim() ?? ""; if (!host) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } + const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host"); if (!pairingCode) { - throw new Error("Enter a pairing code."); + throw new RemotePairingCodeMissingError({ host: normalizedHost.host }); } - const normalizedHost = normalizeRemoteBaseUrl(host); return { credential: pairingCode, httpBaseUrl: toHttpBaseUrl(normalizedHost), From 5b1b35c773e8ad4a85ea2f3dd277b698bf329f21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:28:06 -0700 Subject: [PATCH 120/142] [codex] Preserve desktop shell environment probe failures (#3383) Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.test.ts | 57 ++++++++++++- .../src/shell/DesktopShellEnvironment.ts | 84 ++++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 195902c3c92..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,6 +67,7 @@ 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, @@ -68,7 +77,11 @@ function runShellEnvironment(input: { ); 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 62a3b6efc91..8219f18b7a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ 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 ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -20,6 +21,44 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } +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, { @@ -127,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) => { @@ -175,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, @@ -193,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 = ( @@ -206,12 +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 = runCommandOutput({ + probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, @@ -234,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, From 833c8ab7c66f4e976c9ae0641d5b1d2780348734 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:28:33 -0700 Subject: [PATCH 121/142] [codex] Remove project setup error constructor wrappers (#3329) Co-authored-by: codex --- .../project/ProjectSetupScriptRunner.test.ts | 51 +++++++++++ .../src/project/ProjectSetupScriptRunner.ts | 91 ++++++++++--------- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index d7a1bd15c58..e8d771b74df 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -3,11 +3,16 @@ 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", @@ -145,4 +150,50 @@ describe("ProjectSetupScriptRunner", () => { }).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 index dc97da51f24..41bf0fabf48 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return "Project was not found for setup script execution."; + return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`; } } @@ -78,32 +78,6 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} -const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError); - -function operationError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], - cause: unknown, -): ProjectSetupScriptOperationError { - return new ProjectSetupScriptOperationError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - operation, - cause, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); -} - -function mapRunnerError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], -) { - return Effect.mapError((cause: unknown) => - isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause), - ); -} - export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; @@ -111,26 +85,43 @@ export const make = Effect.gen(function* () { 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), mapRunnerError(input, "resolveProject")) + ? 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), mapRunnerError(input, "resolveProject")) + ? 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({ - threadId: input.threadId, - worktreePath: input.worktreePath, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); + return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); } const script = setupProjectScript(project.scripts); @@ -155,14 +146,32 @@ export const make = Effect.gen(function* () { worktreePath: input.worktreePath, env, }) - .pipe(mapRunnerError(input, "openTerminal")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); yield* terminalManager .write({ threadId: input.threadId, terminalId, data: `${script.command}\r`, }) - .pipe(mapRunnerError(input, "writeCommand")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); return { status: "started", From 1dc36cef73962441befca71678d09fbefdd08c9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:29:28 -0700 Subject: [PATCH 122/142] Structure relay auth parsing errors (#3290) Co-authored-by: codex --- packages/shared/src/relayAuth.test.ts | 62 ++++++++++++++++++++ packages/shared/src/relayAuth.ts | 82 +++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index dc06ce5323c..3abff9b5210 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -1,17 +1,79 @@ import { describe, expect, it } from "vite-plus/test"; import { + ClerkPublishableKeyDecodeError, + ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, + clerkFrontendApiUrlFromPublishableKey, isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + describe("Clerk relay auth", () => { it("derives a custom Frontend API hostname from a Clerk publishable key", () => { expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( "clerk.t3.codes", ); + expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( + "https://clerk.t3.codes", + ); + }); + + it("preserves Clerk publishable key decoding failures", () => { + const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%")); + + expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError); + expect(error).toMatchObject({ keyPrefix: "pk_test" }); + expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test)."); + }); + + it("reports semantic frontend API failures without inventing a cause", () => { + const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_")); + const pathFrontendApi = "clerk.t3.codes/path"; + const pathError = captureError(() => + clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)), + ); + + expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(emptyError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: "", + reason: "empty", + }); + expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(pathError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: pathFrontendApi, + reason: "contains-path", + }); + expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined(); + }); + + it("preserves URL parser failures for decoded frontend APIs", () => { + const frontendApi = "[invalid-host"; + const error = captureError(() => + clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)), + ); + + expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(error).toMatchObject({ + keyPrefix: "pk_test", + frontendApi, + reason: "invalid-url", + }); + expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); it("allows standard Clerk hosts and an exact configured custom hostname", () => { diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index bf5fb61ee3b..a384db77d8a 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,14 +1,84 @@ -export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { +import * as Schema from "effect/Schema"; + +const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]); + +export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyDecodeError", + { + keyPrefix: ClerkPublishableKeyPrefix, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode Clerk publishable key (${this.keyPrefix}).`; + } +} + +export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyFrontendApiError", + { + keyPrefix: ClerkPublishableKeyPrefix, + frontendApi: Schema.String, + reason: Schema.Literals(["empty", "contains-path", "invalid-url"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`; + } +} + +function parseClerkFrontendApi(publishableKey: string): { + readonly hostname: string; + readonly url: string; +} { + const keyPrefix = publishableKey.startsWith("pk_test_") + ? "pk_test" + : publishableKey.startsWith("pk_live_") + ? "pk_live" + : "unknown"; const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); - const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); - if (frontendApi.length === 0 || frontendApi.includes("/")) { - throw new Error("Invalid Clerk publishable key."); + let frontendApi: string; + try { + frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + } catch (cause) { + throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause }); } - return `https://${frontendApi}`; + + if (frontendApi.length === 0) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "empty", + }); + } + if (frontendApi.includes("/")) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "contains-path", + }); + } + + const url = `https://${frontendApi}`; + try { + return { hostname: new URL(url).hostname, url }; + } catch (cause) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "invalid-url", + cause, + }); + } +} + +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { + return parseClerkFrontendApi(publishableKey).url; } export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { - return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; + return parseClerkFrontendApi(publishableKey).hostname; } export function isAllowedClerkFrontendApiHostname( From e01b1903de403c720792a896d8c50ebcd8163b44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:30:17 -0700 Subject: [PATCH 123/142] [codex] Structure process diagnostics failures (#3389) Co-authored-by: codex --- .../diagnostics/ProcessDiagnostics.test.ts | 39 ++++++ .../src/diagnostics/ProcessDiagnostics.ts | 111 ++++++++++++------ 2 files changed, 117 insertions(+), 33 deletions(-) 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 40e7f347be1..b39d560a228 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service< class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsQueryTimeoutError", - { command: 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.`; + return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; } } @@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsNotDescendantError", - { pid: Schema.Number }, + { + pid: Schema.Number, + serverPid: Schema.Number, + }, ) { override get message(): string { return `Process ${this.pid} is not a live descendant of the T3 server.`; @@ -312,20 +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 }) { +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( @@ -346,36 +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( - new ProcessDiagnosticsQueryTimeoutError({ - command: input.command, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : new ProcessDiagnosticsQueryFailedError({ + }).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, - cause, + 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, @@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "ps", - stderr: result.stderr.trim() || "ps failed.", + 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)), @@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "powershell.exe", - stderr: result.stderr.trim() || "PowerShell process query failed.", + 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)), @@ -464,6 +508,7 @@ function assertDescendantPid( : Effect.fail( new ProcessDiagnosticsNotDescendantError({ pid, + serverPid: process.pid, }), ); }), From 7eb7b4f4a36aea3b858494e5983ee9bc9a288933 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:31:09 -0700 Subject: [PATCH 124/142] [codex] Structure release metadata failures (#3296) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 23 +++++-- scripts/resolve-nightly-release.ts | 17 ++++- scripts/resolve-previous-release-tag.test.ts | 39 ++++++++++++ scripts/resolve-previous-release-tag.ts | 65 ++++++++++++-------- 4 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 scripts/resolve-previous-release-tag.test.ts diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 82b25737a58..ecc94c57f59 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { resolveNightlyBaseVersion, @@ -12,11 +13,23 @@ it("strips prerelease and build metadata when deriving the nightly base version" assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); }); -it("bumps the patch version before deriving nightly prerelease versions", () => { - assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18"); - assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); - assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); -}); +it.effect("bumps the patch version before deriving nightly prerelease versions", () => + Effect.gen(function* () { + assert.equal(yield* resolveNightlyTargetVersion("0.0.17"), "0.0.18"); + assert.equal(yield* resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); + assert.equal(yield* resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); + }), +); + +it.effect("reports the invalid desktop package version", () => + Effect.gen(function* () { + const error = yield* resolveNightlyTargetVersion("nightly").pipe(Effect.flip); + + assert.equal(error._tag, "InvalidDesktopPackageVersionError"); + assert.equal(error.version, "nightly"); + assert.equal(error.message, "Invalid desktop package version 'nightly'."); + }), +); it("derives nightly metadata including the short commit sha in the release name", () => { assert.deepStrictEqual( diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index e3f064305bf..ae6bc323c67 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -29,6 +29,17 @@ const DesktopPackageJsonSchema = Schema.Struct({ version: Schema.NonEmptyString, }); +export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass()( + "InvalidDesktopPackageVersionError", + { + version: Schema.String, + }, +) { + override get message(): string { + return `Invalid desktop package version '${this.version}'.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -42,11 +53,11 @@ export const resolveNightlyTargetVersion = (version: string) => { const stableCore = resolveNightlyBaseVersion(version); const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore); if (!match) { - throw new Error(`Invalid desktop package version '${version}'.`); + return Effect.fail(new InvalidDesktopPackageVersionError({ version })); } const [, major, minor, patch] = match; - return `${major}.${minor}.${Number(patch) + 1}`; + return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`); }; export const resolveNightlyReleaseMetadata = ( @@ -76,7 +87,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( const packageJson = yield* fs .readFileString(packageJsonPath) .pipe(Effect.flatMap(decodeDesktopPackageJson)); - return resolveNightlyTargetVersion(packageJson.version); + return yield* resolveNightlyTargetVersion(packageJson.version); }); const writeOutput = Effect.fn("writeOutput")(function* ( diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts new file mode 100644 index 00000000000..ecf564c005e --- /dev/null +++ b/scripts/resolve-previous-release-tag.test.ts @@ -0,0 +1,39 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; + +it.effect("selects the latest earlier stable tag and ignores nightlies", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [ + "v1.1.0", + "v1.1.1-nightly.20260619.1", + "v1.1.2", + "v1.2.0", + ]); + + assert.equal(previous, "v1.1.2"); + }), +); + +it.effect("accepts legacy nightly tags when selecting the previous nightly", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("nightly", "v1.2.0-nightly.20260620.2", [ + "nightly-v1.2.0-nightly.20260620.1", + "v1.1.0-nightly.20260619.9", + ]); + + assert.equal(previous, "nightly-v1.2.0-nightly.20260620.1"); + }), +); + +it.effect("reports the invalid tag with its release channel", () => + Effect.gen(function* () { + const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip); + + assert.equal(error._tag, "InvalidReleaseTagError"); + assert.equal(error.channel, "nightly"); + assert.equal(error.currentTag, "v1.2.0"); + assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); + }), +); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index f75c3a4f8a4..8b1f1fc9648 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -15,6 +15,18 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const ReleaseChannel = Schema.Literals(["stable", "nightly"]); type ReleaseChannel = typeof ReleaseChannel.Type; +export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "InvalidReleaseTagError", + { + channel: ReleaseChannel, + currentTag: Schema.String, + }, +) { + override get message(): string { + return `Invalid ${this.channel} release tag '${this.currentTag}'.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -121,41 +133,44 @@ const parseNightlyTag = (tag: string): NightlyVersion | undefined => { }; }; -const resolvePreviousReleaseTag = ( +export const resolvePreviousReleaseTag = ( channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray, -): string | undefined => { - if (channel === "stable") { - const current = parseStableTag(currentTag); +) => + Effect.gen(function* () { + if (channel === "stable") { + const current = parseStableTag(currentTag); + if (!current) { + return yield* new InvalidReleaseTagError({ channel, currentTag }); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .filter( + (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + ) + .filter((entry) => compareStableVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; + } + + const current = parseNightlyTag(currentTag); if (!current) { - throw new Error(`Invalid stable release tag '${currentTag}'.`); + return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) .filter( - (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareStableVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); return candidates[0]?.tag; - } - - const current = parseNightlyTag(currentTag); - if (!current) { - throw new Error(`Invalid nightly release tag '${currentTag}'.`); - } - - const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) - .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); - - return candidates[0]?.tag; -}; + }); const listGitTags = Effect.fn("listGitTags")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -205,7 +220,7 @@ const command = Command.make( }, ({ channel, currentTag, githubOutput }) => listGitTags().pipe( - Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), + Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); From 708bc7091dcdf5073a374464ffcbe45ba520b8fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:31:57 -0700 Subject: [PATCH 125/142] Structure server environment ID failures (#3286) Co-authored-by: codex --- .../src/environment/ServerEnvironment.test.ts | 103 +++++++++--------- .../src/environment/ServerEnvironment.ts | 52 +++++++-- 2 files changed, 98 insertions(+), 57 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 665447589eb..6b3290246fe 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,16 +1,18 @@ -// @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 * 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))); @@ -68,62 +70,63 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); - it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + 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-read-error-test-", + prefix: "t3-server-environment-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 methodByOperation = { + check: "exists", + read: "readFileString", + write: "writeFileString", + } as const; - const exit = 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)), + 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.exit, - ); + Effect.flip, + ); - expect(Exit.isFailure(exit)).toBe(true); - expect(writeAttempts).toEqual([]); - expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( - "persisted-environment-id\n", - ); + 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 index 433a9d3f02a..b5fbd8e1088 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -6,12 +6,26 @@ 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, { @@ -55,22 +69,46 @@ export const make = Effect.gen(function* () { const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { - const exists = yield* fileSystem - .exists(serverConfig.environmentIdPath) - .pipe(Effect.orElseSucceed(() => false)); + 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())); + 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`); + 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; From 2c16edd0cb1169ce916e3da6a736d371954532a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:32:47 -0700 Subject: [PATCH 126/142] [codex] Structure desktop bridge state errors (#3381) Co-authored-by: codex --- .../src/state/desktopNetworkAccess.test.ts | 37 ++++++++++ apps/web/src/state/desktopNetworkAccess.ts | 67 ++++++++++++------- apps/web/src/state/desktopSshHosts.test.ts | 22 ++++++ apps/web/src/state/desktopSshHosts.ts | 30 +++++---- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts index 7af13cbbcfc..0dde5f7d7dc 100644 --- a/apps/web/src/state/desktopNetworkAccess.test.ts +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -1,4 +1,5 @@ import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { }; const advertisedEndpoints: ReadonlyArray = []; +const serverExposureLoadCause = new Error("exposure failed"); +const advertisedEndpointsLoadCause = new Error("endpoints failed"); describe("desktopNetworkAccessState", () => { it("retains the loaded snapshot when the settings screen remounts", async () => { @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { remount(); registry.dispose(); }); + + it.each([ + { + cause: serverExposureLoadCause, + expectedTag: "DesktopServerExposureStateLoadError", + getAdvertisedEndpoints: async () => advertisedEndpoints, + getServerExposureState: async () => Promise.reject(serverExposureLoadCause), + }, + { + cause: advertisedEndpointsLoadCause, + expectedTag: "DesktopAdvertisedEndpointsLoadError", + getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause), + getServerExposureState: async () => serverExposureState, + }, + ])("retains the $expectedTag cause", async (testCase) => { + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints: testCase.getAdvertisedEndpoints, + getServerExposureState: testCase.getServerExposureState, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: testCase.expectedTag, + cause: testCase.cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopNetworkAccess.ts b/apps/web/src/state/desktopNetworkAccess.ts index 150a256bc68..07580fcd164 100644 --- a/apps/web/src/state/desktopNetworkAccess.ts +++ b/apps/web/src/state/desktopNetworkAccess.ts @@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot { readonly serverExposureState: DesktopServerExposureState; } -class DesktopNetworkAccessError extends Schema.TaggedErrorClass()( - "DesktopNetworkAccessError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} +class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass()( + "DesktopNetworkAccessUnavailableError", + {}, +) { + override get message(): string { + return "Desktop network access is unavailable."; + } +} + +class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass()( + "DesktopServerExposureStateLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load desktop server exposure state."; + } +} + +class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass()( + "DesktopAdvertisedEndpointsLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load advertised desktop endpoints."; + } +} function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopNetworkAccessError({ - message: "Desktop network access is unavailable.", - }); + return yield* new DesktopNetworkAccessUnavailableError(); } - return yield* Effect.tryPromise({ - try: async (): Promise => { - const [serverExposureState, advertisedEndpoints] = await Promise.all([ - bridge.getServerExposureState(), - bridge.getAdvertisedEndpoints(), - ]); - return { advertisedEndpoints, serverExposureState }; - }, - catch: (cause) => - new DesktopNetworkAccessError({ - message: - cause instanceof Error ? cause.message : "Failed to load desktop network access.", - cause, + const [serverExposureState, advertisedEndpoints] = yield* Effect.all( + [ + Effect.tryPromise({ + try: () => bridge.getServerExposureState(), + catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), + }), + Effect.tryPromise({ + try: () => bridge.getAdvertisedEndpoints(), + catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), }), - }); + ], + { concurrency: "unbounded" }, + ); + return { + advertisedEndpoints, + serverExposureState, + } satisfies DesktopNetworkAccessSnapshot; }); return Atom.make(loadDesktopNetworkAccess()).pipe( diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 571704a95f5..83eda60158c 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -1,4 +1,5 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { remount(); registry.dispose(); }); + + it("retains the desktop bridge failure as the discovery error cause", async () => { + const cause = new Error("ssh config unavailable"); + const atom = createDesktopSshHostsStateAtom(() => ({ + discoverSshHosts: async () => Promise.reject(cause), + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: "DesktopSshDiscoveryError", + cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 47b2c87e97c..8e4022cbecf 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity"; type DesktopSshDiscoveryBridge = Pick; +class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryUnavailableError", + {}, +) { + override get message(): string { + return "Desktop SSH host discovery is unavailable."; + } +} + class DesktopSshDiscoveryError extends Schema.TaggedErrorClass()( "DesktopSshDiscoveryError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to discover SSH hosts."; + } +} function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopSshDiscoveryError({ - message: "Desktop SSH host discovery is unavailable.", - }); + return yield* new DesktopSshDiscoveryUnavailableError(); } return yield* Effect.tryPromise({ try: (): Promise> => bridge.discoverSshHosts(), - catch: (cause) => - new DesktopSshDiscoveryError({ - message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", - cause, - }), + catch: (cause) => new DesktopSshDiscoveryError({ cause }), }); }); From 32c7f90d9e6d4045a709b2a0faf30b977bdf26da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:36:08 -0700 Subject: [PATCH 127/142] [codex] Structure APNs delivery queue errors (#3326) Co-authored-by: codex --- .../agentActivity/ApnsDeliveryQueue.test.ts | 79 +++++++++++++++++++ .../src/agentActivity/ApnsDeliveryQueue.ts | 77 +++++++++++++++--- 2 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts new file mode 100644 index 00000000000..b3a8083efe8 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts @@ -0,0 +1,79 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; + +const config: RelayConfiguration.RelayConfiguration["Service"] = { + relayIssuer: "https://relay.example.com", + apns: { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make("apns-private-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + }, + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}; + +describe("ApnsDeliveryQueue", () => { + it.effect("preserves job identity and the queue sender cause", () => { + const cause = new Error("queue unavailable"); + const senderCause = new Cloudflare.QueueSendError({ + message: cause.message, + cause, + }); + const layer = ApnsDeliveryQueue.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide( + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: () => Effect.fail(senderCause), + }), + ), + ); + + return Effect.gen(function* () { + const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const error = yield* Effect.flip( + queue.enqueuePushNotification({ + userId: "user-1", + deviceId: "device-1", + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env-1", + threadId: "thread-1", + deepLink: "/threads/env-1/thread-1", + }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryQueueSendError", + operation: "send", + jobId: expect.any(String), + kind: "push_notification", + userId: "user-1", + deviceId: "device-1", + cause: senderCause, + }); + expect(senderCause.cause).toBe(cause); + expect(error.message).toBe( + "Failed to enqueue APNs push notification delivery during send for device device-1.", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 980eab16953..6c1fd79dc1c 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -7,7 +7,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; +import { + RelayDeliveryKind as RelayDeliveryKindSchema, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; import { sanitizeAgentActivityAggregateState, @@ -24,10 +27,17 @@ import * as RelayConfiguration from "../Config.ts"; export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass()( "ApnsDeliveryQueueSendError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["generate-job-id", "send"]), + jobId: Schema.NullOr(Schema.String), + kind: RelayDeliveryKindSchema, + userId: Schema.String, + deviceId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to enqueue APNs delivery"; + return `Failed to enqueue APNs ${this.kind.replaceAll("_", " ")} delivery during ${this.operation} for device ${this.deviceId}.`; } } @@ -36,7 +46,7 @@ export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; export class ApnsDeliveryQueueSender extends Context.Service< ApnsDeliveryQueueSender, { - readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; + readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; } >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} @@ -73,7 +83,17 @@ export const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -88,7 +108,19 @@ export const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: input.kind, @@ -110,7 +142,17 @@ export const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -128,7 +170,19 @@ export const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: "push_notification" as const, @@ -155,10 +209,9 @@ export const layerCloudflareQueues = ( ApnsDeliveryQueueSender, ApnsDeliveryQueueSender.of({ send: (body) => - sender.send(body).pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), + sender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), }), ), ), From d512deac84aecb8ec2e4978599ce31a3185f5350 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:37:01 -0700 Subject: [PATCH 128/142] [codex] Structure preview URL failures (#3275) Co-authored-by: codex --- apps/server/src/preview/Manager.test.ts | 26 ++++++++++++++ apps/server/src/preview/Manager.ts | 28 +++++++++------ packages/contracts/src/preview.ts | 11 +++--- packages/shared/src/preview.test.ts | 46 ++++++++++++++++++++++--- packages/shared/src/preview.ts | 46 +++++++++++++++++-------- 5 files changed, 122 insertions(+), 35 deletions(-) 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 159932c4bdc..fe3557c157f 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -24,9 +24,9 @@ import { type PreviewSessionSnapshot, } from "@t3tools/contracts"; import { + isPreviewUrlNormalizationError, newPreviewTabId, normalizePreviewUrl, - PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -82,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)); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 044b8fbbd07..457e66ee07f 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( "PreviewInvalidUrlError", { - rawUrl: Schema.String, - detail: Schema.optional(Schema.String), + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]), + protocol: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message() { - return this.detail - ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` - : `Invalid preview URL: ${this.rawUrl}`; + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 6030686d3ed..fec4203c533 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => { }); it("rejects empty input", () => { - expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl(" "); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect("cause" in (error as object)).toBe(false); + } }); it("rejects unsupported protocols", () => { - expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); - expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl("ftp://example.com"); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: "ftp://example.com".length, + reason: "unsupported-protocol", + protocol: "ftp:", + }); + } }); - it("rejects unparseable junk", () => { - expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + it("rejects unparseable input without retaining credentials or tokens", () => { + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + try { + normalizePreviewUrl(rawUrl); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect((error as PreviewUrlNormalizationError).cause).toBeInstanceOf(Error); + expect((error as PreviewUrlNormalizationError).message).not.toContain( + ((error as PreviewUrlNormalizationError).cause as Error).message, + ); + expect((error as PreviewUrlNormalizationError).message).not.toMatch( + /user|password|access_token|secret|fragment/, + ); + } }); }); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index cc5a765ddcb..926b30966e5 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -4,6 +4,8 @@ * on what counts as "loopback" and how to normalise a free-form URL string. */ +import * as Schema from "effect/Schema"; + const TAB_ID_PREFIX = "tab_"; let nextPreviewTabSequence = 0; @@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean { } } -export class PreviewUrlNormalizationError extends Error { - readonly rawUrl: string; - readonly detail: string; - constructor(rawUrl: string, detail: string) { - super(`Invalid preview URL: ${rawUrl} (${detail})`); - this.name = "PreviewUrlNormalizationError"; - this.rawUrl = rawUrl; - this.detail = detail; +export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( + "PreviewUrlNormalizationError", + { + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]), + protocol: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } +export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError); + +function previewUrlProtocol(rawUrl: string): string | undefined { + return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); +} + /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. * @@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error { export function normalizePreviewUrl(rawUrl: string): string { const trimmed = rawUrl.trim(); if (trimmed.length === 0) { - throw new PreviewUrlNormalizationError(rawUrl, "empty"); + throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); } const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); const candidate = trimmed.includes("://") @@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string { try { parsed = new URL(candidate); } catch (cause) { - throw new PreviewUrlNormalizationError( - rawUrl, - cause instanceof Error ? cause.message : "unparseable", - ); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "parse", + protocol: previewUrlProtocol(candidate), + cause, + }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "unsupported-protocol", + protocol: parsed.protocol, + }); } return parsed.href; } From d8bf307d22663a3c458dc521a014fd21bdb60b98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:37:50 -0700 Subject: [PATCH 129/142] [codex] Enrich process runner errors (#3268) Co-authored-by: codex --- .../ServerEnvironmentLabel.test.ts | 2 +- apps/server/src/processRunner.test.ts | 92 ++++++++++++++++++- apps/server/src/processRunner.ts | 65 +++++++++---- 3 files changed, 135 insertions(+), 24 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index bc30bd0ce19..4bc9647fba5 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -138,7 +138,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.fail( new ProcessRunner.ProcessSpawnError({ command: "scutil", - args: ["--get", "ComputerName"], + argumentCount: 2, cause: new Error("spawn scutil ENOENT"), }), ), diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 2c9d9f95038..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"; @@ -55,7 +56,9 @@ function makeHandle(input: { } function makeSpawner( - f: (command: ChildProcessCommand) => Effect.Effect, + f: ( + command: ChildProcessCommand, + ) => Effect.Effect, ) { return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); } @@ -159,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) }))); @@ -169,7 +210,39 @@ describe("runProcess", () => { maxOutputBytes: 128, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessRunner.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"); }), ); @@ -272,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); @@ -279,7 +354,18 @@ describe("runProcess", () => { yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); - expect(error).toBeInstanceOf(ProcessRunner.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"); }), ); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5f01fcc344b..c1ee2b2cb0c 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -45,23 +45,29 @@ export interface ProcessRunOutput { const ProcessInvocationFields = { command: Schema.String, - args: Schema.Array(Schema.String), + argumentCount: Schema.Number, cwd: Schema.optional(Schema.String), + spawnCwd: Schema.optional(Schema.String), }; const formatProcessInvocation = (input: { readonly command: string; - readonly args: ReadonlyArray; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; }): string => { - const command = [input.command, ...input.args].join(" "); - return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`; + const executionCwd = input.spawnCwd ?? input.cwd; + return executionCwd === undefined + ? `'${input.command}'` + : `'${input.command}' in '${executionCwd}'`; }; 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(), }, ) { @@ -74,6 +80,7 @@ export class ProcessStdinError extends Schema.TaggedErrorClass; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; readonly streamName: "stdout" | "stderr"; readonly stream: Stream.Stream; readonly maxOutputBytes: number; @@ -174,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, }), @@ -203,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, }), ); } @@ -259,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), }), ); @@ -300,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, }), ), @@ -328,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, @@ -338,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, @@ -354,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, }), From b6e384fb273b56ab9d12237f27684d1392be1692 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:40:08 -0700 Subject: [PATCH 130/142] [codex] Preserve trace IDs across error causes (#3426) Co-authored-by: codex --- .../src/errors/errorTrace.test.ts | 19 ++++++++++ .../client-runtime/src/errors/errorTrace.ts | 38 +++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/errors/errorTrace.test.ts b/packages/client-runtime/src/errors/errorTrace.test.ts index 075049bd55e..25509899995 100644 --- a/packages/client-runtime/src/errors/errorTrace.test.ts +++ b/packages/client-runtime/src/errors/errorTrace.test.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import { describe, expect, it } from "vite-plus/test"; import { findErrorTraceId } from "./errorTrace.ts"; @@ -22,4 +23,22 @@ describe("findErrorTraceId", () => { expect(findErrorTraceId(error)).toBeNull(); }); + + it("finds trace metadata in Effect cause branches", () => { + const cause = Cause.fromReasons([ + Cause.makeFailReason(new Error("first failure")), + Cause.makeFailReason({ traceId: "trace-secondary" }), + ]); + + expect(findErrorTraceId(cause)).toBe("trace-secondary"); + }); + + it("finds trace metadata in aggregate error branches", () => { + const error = new AggregateError( + [new Error("first failure"), { traceId: "trace-aggregate" }], + "request failed", + ); + + expect(findErrorTraceId(error)).toBe("trace-aggregate"); + }); }); diff --git a/packages/client-runtime/src/errors/errorTrace.ts b/packages/client-runtime/src/errors/errorTrace.ts index ec1b2a6b2cd..74deb37c4f3 100644 --- a/packages/client-runtime/src/errors/errorTrace.ts +++ b/packages/client-runtime/src/errors/errorTrace.ts @@ -1,17 +1,49 @@ +import * as Cause from "effect/Cause"; + +const MAX_ERROR_TRACE_NODES = 128; + export function findErrorTraceId(error: unknown): string | null { const seen = new Set(); - let current: unknown = error; + const pending: Array = [error]; + let inspectedNodeCount = 0; - while (typeof current === "object" && current !== null && !seen.has(current)) { + while (pending.length > 0 && inspectedNodeCount < MAX_ERROR_TRACE_NODES) { + const current = pending.pop(); + inspectedNodeCount += 1; + if (typeof current !== "object" || current === null || seen.has(current)) { + continue; + } seen.add(current); const record = current as { readonly cause?: unknown; + readonly errors?: unknown; readonly traceId?: unknown; }; if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { return record.traceId; } - current = record.cause; + + if (Array.isArray(record.errors)) { + for (let index = record.errors.length - 1; index >= 0; index -= 1) { + pending.push(record.errors[index]); + } + } + if (Cause.isCause(current)) { + for (let index = current.reasons.length - 1; index >= 0; index -= 1) { + const reason = current.reasons[index]; + switch (reason?._tag) { + case "Fail": + pending.push(reason.error); + break; + case "Die": + pending.push(reason.defect); + break; + } + } + } + if ("cause" in record) { + pending.push(record.cause); + } } return null; From 13a4789a5d2aabd7377fdb15509f17bcc779d84b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:40:36 -0700 Subject: [PATCH 131/142] [codex] Structure terminal adapter startup defects (#3425) Co-authored-by: codex --- .../server/src/terminal/BunPtyAdapter.test.ts | 33 +++++++++++++++++-- apps/server/src/terminal/BunPtyAdapter.ts | 15 +++++++-- .../src/terminal/NodePtyAdapter.test.ts | 30 +++++++++++++++++ apps/server/src/terminal/NodePtyAdapter.ts | 30 +++++++++++++++-- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts index 39e811db3a9..e04a54e6d33 100644 --- a/apps/server/src/terminal/BunPtyAdapter.test.ts +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -1,9 +1,13 @@ -import { expect, it } from "@effect/vitest"; +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 { BunPtyOperationUnavailableError } from "./BunPtyAdapter.ts"; +import * as BunPtyAdapter from "./BunPtyAdapter.ts"; it("describes unavailable Bun PTY operations structurally", () => { - const error = new BunPtyOperationUnavailableError({ + const error = new BunPtyAdapter.BunPtyOperationUnavailableError({ operation: "resize", pid: 42, }); @@ -15,3 +19,26 @@ it("describes unavailable Bun PTY operations structurally", () => { }); 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/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts index 5d7a44a1071..88b68940de1 100644 --- a/apps/server/src/terminal/BunPtyAdapter.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -7,6 +7,17 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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", { @@ -109,9 +120,7 @@ class BunPtyProcess implements PtyAdapter.PtyProcess { export const make = Effect.fn("BunPtyAdapter.make")(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 yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform })); } return PtyAdapter.PtyAdapter.of({ spawn: (input) => diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 798e96e3a26..ed87440d499 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -1,7 +1,9 @@ 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"; @@ -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/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 7518901bfdd..ac06e1edfab 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -4,10 +4,26 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as PtyAdapter from "./PtyAdapter.ts"; +export class NodePtyModuleLoadError extends Schema.TaggedErrorClass()( + "NodePtyModuleLoadError", + { + platform: Schema.String, + architecture: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load node-pty for ${this.platform}-${this.architecture}.`; + } +} + +type NodePtyModuleLoader = () => Promise; + let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { @@ -94,13 +110,23 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } } -export const make = Effect.fn("NodePtyAdapter.make")(function* () { +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.promise(() => import("node-pty")); + const nodePty = yield* Effect.tryPromise({ + try: loadNodePtyModule, + catch: (cause) => + new NodePtyModuleLoadError({ + platform, + architecture, + cause, + }), + }).pipe(Effect.orDie); const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( ensureNodePtySpawnHelperExecutable().pipe( From eacceb9e7b3791a9fa3d2d8dfb212f4b43e3c91d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:41:29 -0700 Subject: [PATCH 132/142] [codex] Bound shared schema diagnostics (#3424) Co-authored-by: codex --- packages/shared/src/schemaJson.test.ts | 96 ++++++++++++++++- packages/shared/src/schemaJson.ts | 140 +++++++++++++++++++++++-- 2 files changed, 228 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 1cc6e38d919..c808a9b7c51 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -1,7 +1,15 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { extractJsonObject, fromLenientJson } from "./schemaJson.ts"; +import { + decodeJsonResult, + extractJsonObject, + formatSchemaError, + fromLenientJson, +} from "./schemaJson.ts"; const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown)); @@ -48,4 +56,90 @@ Done.`), it("rejects malformed JSON after lenient preprocessing", () => { expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); + + it("formats schema failures with paths without exposing invalid values", () => { + const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const decoded = decodeCredential('{"token":"credential=secret-value"}'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + expect(formatSchemaError(decoded.failure)).toBe('Invalid type\n at ["token"]'); + } + }); + + it("preserves nested paths reported by schema filters", () => { + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ + path: ["session", "token"], + issue: "credential is invalid", + })), + ), + ); + const decoded = decode('"credential=secret-value"'); + + expect(Result.isFailure(decoded)).toBe(true); + if (Result.isFailure(decoded)) { + const diagnostic = formatSchemaError(decoded.failure); + expect(diagnostic).toBe('Invalid value\n at ["session"]["token"]'); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("does not expose malformed lenient JSON input in diagnostics", () => { + const decode = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); + const exit = decode('{"token":"credential=secret-value",,}'); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const diagnostic = formatSchemaError(exit.cause); + expect(diagnostic).toBe("Invalid value"); + expect(diagnostic).not.toContain("credential=secret-value"); + } + }); + + it("summarizes unexpected defects without serializing their messages", () => { + const diagnostic = formatSchemaError(Cause.die(new Error("credential=secret-value"))); + + expect(diagnostic).toBe( + "Schema validation failed (failureCount=0, defectCount=1, interruptionCount=0).", + ); + }); + + it("bounds the number of formatted schema issues", () => { + const decode = decodeJsonResult(Schema.Struct({ token: Schema.Number })); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`{"token":"credential=secret-value-${index}"}`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.match(/Invalid type/g)).toHaveLength(8); + expect(diagnostic).toContain("... and 2 more issue(s)"); + }); + + it("retains the omitted issue count when bounding long diagnostics", () => { + const longPath = Array.from({ length: 16 }, (_, index) => `${index}-${"segment".repeat(16)}`); + const decode = decodeJsonResult( + Schema.String.check( + Schema.makeFilter(() => ({ path: longPath, issue: "credential is invalid" })), + ), + ); + const failures: Array> = []; + for (let index = 0; index < 10; index += 1) { + const decoded = decode(`"credential=secret-value-${index}"`); + if (Result.isFailure(decoded)) { + failures.push(decoded.failure); + } + } + + const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons)); + const diagnostic = formatSchemaError(cause); + expect(diagnostic.length).toBeLessThanOrEqual(2_048); + expect(diagnostic.endsWith("\n... and 2 more issue(s)")).toBe(true); + }); }); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 8b76d9e0a2d..04d26d9c229 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -8,6 +8,104 @@ import * as SchemaGetter from "effect/SchemaGetter"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; +const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16; +const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64; +const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048; + +interface SchemaDiagnosticIssue { + readonly message: string; + readonly path: ReadonlyArray; +} + +// Schema's default formatter includes actual values. These diagnostics cross +// process and UI boundaries, so retain only issue kinds and bounded paths. + +function truncateDiagnostic(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; +} + +function formatDiagnosticPathSegment(key: PropertyKey): string { + if (typeof key === "number") { + return `[${key}]`; + } + const value = truncateDiagnostic( + typeof key === "symbol" ? String(key) : key, + MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH, + ); + return `[${JSON.stringify(value)}]`; +} + +function formatDiagnosticIssue(issue: SchemaDiagnosticIssue): string { + if (issue.path.length === 0) { + return issue.message; + } + const path = issue.path + .slice(0, MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS) + .map(formatDiagnosticPathSegment) + .join(""); + const suffix = issue.path.length > MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS ? "[...]" : ""; + return `${issue.message}\n at ${path}${suffix}`; +} + +function schemaDiagnosticMessage(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "InvalidType": + return "Invalid type"; + case "InvalidValue": + case "Filter": + case "AnyOf": + case "Encoding": + case "Pointer": + case "Composite": + return "Invalid value"; + case "MissingKey": + return "Missing key"; + case "UnexpectedKey": + return "Unexpected key"; + case "Forbidden": + return "Forbidden operation"; + case "OneOf": + return "Expected exactly one schema member to match"; + } +} + +function collectSchemaDiagnosticIssues( + issue: SchemaIssue.Issue, + path: ReadonlyArray, + diagnostics: Array, +): number { + switch (issue._tag) { + case "Encoding": + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + case "Filter": + if (issue.issue._tag !== "InvalidValue") { + return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics); + } + break; + case "Pointer": + return collectSchemaDiagnosticIssues(issue.issue, [...path, ...issue.path], diagnostics); + case "Composite": + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + case "AnyOf": + if (issue.issues.length > 0) { + return issue.issues.reduce( + (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics), + 0, + ); + } + break; + } + + if (diagnostics.length < MAX_SCHEMA_DIAGNOSTIC_ISSUES) { + diagnostics.push({ message: schemaDiagnosticMessage(issue), path }); + } + return 1; +} + export const decodeJsonResult = >( schema: S, ) => { @@ -35,10 +133,40 @@ export const decodeUnknownJsonResult = ) => { - const squashed = Cause.squash(cause); - return Schema.isSchemaError(squashed) - ? SchemaIssue.makeFormatterDefault()(squashed.issue) - : Cause.pretty(cause); + const issues: Array = []; + let issueCount = 0; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + switch (reason._tag) { + case "Fail": + failureCount += 1; + if (Schema.isSchemaError(reason.error)) { + issueCount += collectSchemaDiagnosticIssues(reason.error.issue, [], issues); + } + break; + case "Die": + defectCount += 1; + break; + case "Interrupt": + interruptionCount += 1; + break; + } + } + + if (issues.length === 0) { + return `Schema validation failed (failureCount=${failureCount}, defectCount=${defectCount}, interruptionCount=${interruptionCount}).`; + } + + const omittedIssueCount = issueCount - issues.length; + const formatted = issues.map(formatDiagnosticIssue).join("\n"); + if (omittedIssueCount === 0) { + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH); + } + const suffix = `\n... and ${omittedIssueCount} more issue(s)`; + return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix; }; /** @@ -67,9 +195,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { return decodeJsonString(stripped).pipe( Effect.map(Option.some), - Effect.mapError( - (error) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(error) }), - ), + Effect.mapError((error) => error.issue), ); }); From d60f5c61013dfda68cbbf79bdac2425b73ddaa05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:42:41 -0700 Subject: [PATCH 133/142] [codex] Structure mobile external link failures (#3363) Co-authored-by: codex --- .../features/files/FileMarkdownPreview.tsx | 16 +++-- .../features/files/ThreadFilesRouteScreen.tsx | 12 +--- .../threads/GitActionProgressOverlay.tsx | 5 +- .../features/threads/ThreadGitControls.tsx | 12 ++-- .../features/threads/git/GitOverviewSheet.tsx | 12 ++-- apps/mobile/src/lib/openExternalUrl.test.ts | 58 +++++++++++++++++++ apps/mobile/src/lib/openExternalUrl.ts | 51 ++++++++++++++++ 7 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/lib/openExternalUrl.test.ts create mode 100644 apps/mobile/src/lib/openExternalUrl.ts diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 469a4c983a9..ce762ab184e 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,12 +1,13 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; -import { Linking, ScrollView, Text as NativeText, View } from "react-native"; +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 { @@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { { if (href) { - void Linking.openURL(href); + void tryOpenExternalUrl(href, "markdown-link"); } }} style={{ @@ -143,12 +144,19 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { export function FileMarkdownPreview(props: { readonly markdown: string }) { const styles = useMarkdownPreviewStyles(); + const onLinkPress = useCallback((href: string) => { + void tryOpenExternalUrl(href, "markdown-link"); + }, []); return ( {hasNativeSelectableMarkdownText() ? ( - + ) : ( { if (typeof props.externalPreviewUri === "string") { - void Linking.openURL(props.externalPreviewUri); + void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); } }} > diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index bda966cf16e..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") { diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 59b9af442ef..d5920a72411 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -13,8 +13,9 @@ import { import { useLocalSearchParams, useRouter } from "expo-router"; import Stack from "expo-router/stack"; import { useCallback, useMemo } from "react"; -import { Alert, Linking } from "react-native"; +import { Alert } from "react-native"; import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { basename, getTerminalStatusLabel, @@ -125,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]); diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index d6255a296b7..0db7876a774 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -8,11 +8,12 @@ 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 { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; @@ -83,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]); 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; + } +} From f4ef356215271b47dd2acdd93590f919f548ffd6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:43:21 -0700 Subject: [PATCH 134/142] [codex] structure desktop IPC registration errors (#3291) Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpc.test.ts | 79 ++++++++++++++++++ apps/desktop/src/ipc/DesktopIpc.ts | 102 ++++++++++++++++++------ 2 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/ipc/DesktopIpc.test.ts 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 253bb2774e9..e948571cc62 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -24,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; @@ -39,10 +72,10 @@ export class DesktopIpc extends Context.Service< { readonly handle: ( input: DesktopIpcMethod, - ) => Effect.Effect; + ) => Effect.Effect; readonly handleSync: ( input: DesktopSyncIpcMethod, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/desktop/ipc/DesktopIpc") {} @@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => 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,18 +123,30 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => 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), ); }), }); From cdfecb36a623816f8d032408247e29b007b043f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:48:14 -0700 Subject: [PATCH 135/142] [codex] Structure workspace file system errors (#3274) Co-authored-by: codex --- apps/server/src/server.test.ts | 93 +++++-- .../src/workspace/WorkspaceFileSystem.test.ts | 87 ++++++- .../src/workspace/WorkspaceFileSystem.ts | 226 ++++++++++++++---- apps/server/src/ws.ts | 145 ++++++++--- packages/contracts/src/filesystem.test.ts | 33 +++ packages/contracts/src/filesystem.ts | 39 ++- packages/contracts/src/project.test.ts | 67 ++++++ packages/contracts/src/project.ts | 136 ++++++++++- 8 files changed, 707 insertions(+), 119 deletions(-) create mode 100644 packages/contracts/src/filesystem.test.ts create mode 100644 packages/contracts/src/project.test.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2202d30b837..32a7cc17944 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("preserves workspace rpc failure messages", () => + it.effect("preserves structured workspace rpc failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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 results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => Effect.all({ search: client[WS_METHODS.projectsSearchEntries]({ cwd: invalidWorkspace, - query: "needle", + query: sensitiveQuery, limit: 10, }).pipe(Effect.result), list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( @@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assertTrue(results.search._tag === "Failure"); - assert.equal( - results.search.failure.message, - `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, - ); - assertTrue(results.list._tag === "Failure"); + if ( + results.search._tag !== "Failure" || + results.search.failure._tag !== "ProjectSearchEntriesError" + ) { + assert.fail("Expected a ProjectSearchEntriesError"); + } + const searchError = results.search.failure; assert.equal( - results.list.failure.message, - `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + searchError.message, + `Failed to search workspace entries in '${invalidWorkspace}'.`, ); - assertTrue(results.read._tag === "Failure"); + 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( - results.read.failure.message, - "Failed to read workspace file: Workspace file path resolves outside the project root.", + readError.message, + `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, ); - assertTrue(results.browse._tag === "Failure"); + 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( - results.browse.failure.message, - `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, + 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)), ); @@ -4572,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)), ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index aa2dabb3337..cecffbc1993 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -104,14 +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.message).toBe( - `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, - ); + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-secret.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects directories without manufacturing an I/O cause", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "src", + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects binary files without leaking their contents into the error", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const absolutePath = path.join(cwd, "asset.bin"); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "asset.bin" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(absolutePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "asset.bin", + resolvedPath, + }); + expect("cause" in error).toBe(false); + expect("contents" in error).toBe(false); + }), + ); + + it.effect("preserves the real cause and path for I/O failures", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const resolvedPath = path.join(cwd, "missing.txt"); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "missing.txt" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "missing.txt", + resolvedPath, + operationPath: resolvedPath, + operation: "realpath-target", + }); expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as Error).message).toBe( - "Workspace file path resolves outside the project root.", - ); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 8cd176db3dd..e2dc9cbbb39 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; -export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( - "WorkspaceFileSystemError", +export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemOperationError", { - cwd: Schema.String, - relativePath: Schema.optional(Schema.String), + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + operationPath: Schema.String, operation: Schema.Literals([ - "workspaceFileSystem.readFile", - "workspaceFileSystem.makeDirectory", - "workspaceFileSystem.writeFile", + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", ]), cause: Schema.Defect(), }, ) { override get message(): string { - const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; - return `Workspace file operation '${this.operation}' failed for ${target}.`; + 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, @@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - return yield* Effect.tryPromise({ - try: async () => { - const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - NodeFSP.realpath(input.cwd), - NodeFSP.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 NodeFSP.open(realTargetPath, "r"); - try { - const stat = await handle.stat(); + 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()) { - throw new Error("Workspace path is not a file."); + 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 } = await handle.read(buffer, 0, bytesToRead, 0); + 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)) { - throw new Error("Binary files cannot be previewed as text."); + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); } - const contents = new TextDecoder("utf-8").decode(fileBytes); + return { relativePath: target.relativePath, - contents, + contents: new TextDecoder("utf-8").decode(fileBytes), 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", - cause, }), - }); + (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( @@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, relativePath: input.relativePath, - operation: "workspaceFileSystem.makeDirectory", + resolvedPath: target.absolutePath, + operationPath: path.dirname(target.absolutePath), + operation: "make-directory", cause, }), ), @@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( Effect.mapError( (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", cause, }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 03b609ddcfe..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, @@ -108,7 +112,6 @@ 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(WorkspacePaths.WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } -/** Preserve pre-structured-error display behavior at the RPC boundary. */ -function legacyPlatformFailureDescription(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - /** Preserve the setup runner's broader pre-refactor message normalization. */ function legacySetupFailureDescription(cause: unknown): string { if ( @@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { return String(cause); } -function workspaceEntriesCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesError, -): string { +function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; +} { switch (error._tag) { case "WorkspaceRootNotExistsError": - return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_found", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootCreateFailedError": - return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_create_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootNotDirectoryError": - return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_directory", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceSearchIndexCreateFailed": - return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_create_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; case "WorkspaceSearchIndexScanTimedOut": - return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; + return { + failure: "search_index_scan_timed_out", + normalizedCwd: error.cwd, + timeout: error.timeout, + }; case "WorkspaceSearchIndexSearchFailed": - return `Workspace search failed for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_search_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; default: return unexpectedCompatibilityError(error); } } -function workspaceBrowseCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesBrowseError, -): string { +function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; +} { switch (error._tag) { case "WorkspaceEntriesWindowsPathUnsupportedError": - return "Windows-style paths are only supported on Windows."; + return { failure: "windows_path_unsupported", platform: error.platform }; case "WorkspaceEntriesCurrentProjectRequiredError": - return "Relative filesystem browse paths require a current project."; + return { failure: "current_project_required" }; case "WorkspaceEntriesReadDirectoryError": - return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; + 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); } @@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + ...input, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.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: ${legacyPlatformFailureDescription(cause.cause)}`; - return new ProjectReadFileError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.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" }, ), @@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: workspaceBrowseCompatibilityDetail(cause), + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts new file mode 100644 index 00000000000..45355b73edc --- /dev/null +++ b/packages/contracts/src/filesystem.test.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { FilesystemBrowseError } from "./filesystem.ts"; + +describe("FilesystemBrowseError", () => { + it("derives a stable message from browse context while retaining the cause", () => { + const cause = new Error("sensitive filesystem detail"); + const error = new FilesystemBrowseError({ + cwd: "/workspace", + partialPath: "./src/mai", + failure: "read_directory_failed", + parentPath: "/workspace/src", + cause, + }); + + expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError); + const error = decodeError({ + _tag: "FilesystemBrowseError", + message: "Legacy filesystem browse failure.", + }); + + expect(error.message).toBe("Legacy filesystem browse failure."); + expect(error.partialPath).toBeUndefined(); + expect(error.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 511f8ee19a3..ca4519b4c8b 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ }); export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; +export const FilesystemBrowseFailure = Schema.Literals([ + "windows_path_unsupported", + "current_project_required", + "read_directory_failed", +]); +export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; + +function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class FilesystemBrowseError extends Schema.TaggedErrorClass()( "FilesystemBrowseError", { + partialPath: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(FilesystemBrowseFailure), + parentPath: Schema.optional(TrimmedNonEmptyString), + platform: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // Structured diagnostics stay optional for rolling compatibility with legacy message-only + // payloads, while new call sites must provide the request context and failure classification. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly partialPath: string; + readonly cwd?: string | undefined; + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; + readonly cause?: unknown; + }) { + const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; + super({ + ...props, + message: + decodedFilesystemBrowseErrorMessage(props) ?? + `Failed to browse filesystem path '${props.partialPath}'${cwd}.`, + } as any); + } +} diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts new file mode 100644 index 00000000000..ea9d5a90e7c --- /dev/null +++ b/packages/contracts/src/project.test.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ProjectReadFileError, + ProjectSearchEntriesError, + ProjectWriteFileError, +} from "./project.ts"; + +describe("project RPC errors", () => { + it("derives stable messages from structured request context while retaining causes", () => { + const cause = new Error("sensitive platform detail"); + const searchError = new ProjectSearchEntriesError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 20, + failure: "search_index_search_failed", + normalizedCwd: "/workspace", + detail: "index unavailable", + cause, + }); + const readError = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "src/index.ts", + failure: "operation_failed", + operation: "read", + operationPath: "/workspace/src/index.ts", + resolvedPath: "/workspace/src/index.ts", + cause, + }); + + expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); + expect(searchError.message).not.toContain(cause.message); + expect(searchError.normalizedCwd).toBe("/workspace"); + expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBe(cause); + expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); + expect(readError.message).not.toContain(cause.message); + expect(readError.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError); + const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError); + + const searchError = decodeSearchError({ + _tag: "ProjectSearchEntriesError", + message: "Legacy project search failure.", + query: "legacy sensitive query", + }); + const writeError = decodeWriteError({ + _tag: "ProjectWriteFileError", + message: "Legacy project write failure.", + }); + + expect(searchError.message).toBe("Legacy project search failure."); + expect(searchError.cwd).toBeUndefined(); + expect(searchError.queryLength).toBeUndefined(); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.failure).toBeUndefined(); + expect(writeError.message).toBe("Legacy project write failure."); + expect(writeError.relativePath).toBeUndefined(); + expect(writeError.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 29610845288..338b87096d9 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({ }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; +export const ProjectEntriesFailure = Schema.Literals([ + "workspace_root_not_found", + "workspace_root_create_failed", + "workspace_root_not_directory", + "search_index_create_failed", + "search_index_scan_timed_out", + "search_index_search_failed", +]); +export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; + +type ProjectEntriesFailureContext = { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; + readonly cause?: unknown; +}; + +function decodedProjectErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( "ProjectSearchEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // The structured fields are optional on the wire so newer peers can decode legacy message-only + // failures. New application code must provide them through this constructor. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace entries in '${props.cwd}'.`, + } as any); + } +} export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`, + } as any); + } +} export const ProjectReadFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ }); export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; +export const ProjectFileFailure = Schema.Literals([ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed", +]); +export type ProjectFileFailure = typeof ProjectFileFailure.Type; + +export const ProjectFileOperation = Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", +]); +export type ProjectFileOperation = typeof ProjectFileOperation.Type; + +type ProjectFileFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; + readonly cause?: unknown; +}; + export class ProjectReadFileError extends Schema.TaggedErrorClass()( "ProjectReadFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; export class ProjectWriteFileError extends Schema.TaggedErrorClass()( "ProjectWriteFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} From 731b1a67acb454da7a52491ab08db39c57036699 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:49:08 -0700 Subject: [PATCH 136/142] [codex] structure VCS process errors (#3408) Co-authored-by: codex --- apps/server/src/vcs/VcsProcess.test.ts | 73 +++++++++++++++++++++++++- apps/server/src/vcs/VcsProcess.ts | 59 ++++++++++++++++----- packages/contracts/src/vcs.ts | 49 ++++++++++++++++- 3 files changed, 166 insertions(+), 15 deletions(-) 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 4470a1bfc53..8103c7306a0 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -8,6 +8,7 @@ import { VcsOutputDecodeError, type VcsError, VcsProcessExitError, + type VcsProcessExitFailureKind, VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; @@ -46,19 +47,51 @@ 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(); + + 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 @@ -97,13 +130,15 @@ export const make = Effect.gen(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 { diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 40deeb77da6..728cef3974f 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -62,6 +62,7 @@ export interface VcsProcessErrorContext { readonly operation: string; readonly command: string; readonly cwd: string; + readonly argumentCount?: number; } export interface VcsProcessSpawnFailure { @@ -86,12 +87,26 @@ export interface VcsProcessTimeoutFailure { readonly timeoutMs: number; } +export const VcsProcessExitFailureKind = Schema.Literals([ + "authentication", + "not-found", + "command-failed", +]); +export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; + +export interface VcsProcessExitFailure { + readonly exitCode: number; + readonly stderr: string; + readonly stderrTruncated: boolean; +} + export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( "VcsProcessSpawnError", { operation: Schema.String, command: Schema.String, cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), cause: Schema.Defect(), }, ) { @@ -113,13 +128,43 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass()( @@ -128,6 +173,7 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 11:49:21 -0700 Subject: [PATCH 137/142] [codex] Structure primary environment target failures (#3413) Co-authored-by: codex --- .../environments/primary/bootstrap.test.ts | 76 ++++++++ apps/web/src/environments/primary/index.ts | 7 + apps/web/src/environments/primary/target.ts | 184 +++++++++++++++--- 3 files changed, 240 insertions(+), 27 deletions(-) diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index f3a5c2678ac..e8333c2e078 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { getPrimaryKnownEnvironment, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -43,6 +47,15 @@ function installTestBrowser(url: string) { }); } +function captureThrown(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); +} + describe("environmentBootstrap", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -175,4 +188,67 @@ describe("environmentBootstrap", () => { "http://127.0.0.1:5733/.well-known/t3/environment", ); }); + + it("retains the URL parser cause without exposing the configured URL in its message", () => { + vi.stubEnv("VITE_HTTP_URL", "http://["); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentUrlInvalidError(error)).toBe(true); + if (!isPrimaryEnvironmentUrlInvalidError(error)) { + throw new Error("Expected a structured primary environment URL error."); + } + expect(error).toMatchObject({ + source: "configured", + urlKind: "http-base-url", + message: "Could not parse http-base-url for the configured primary environment target.", + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error.message).not.toContain("http://["); + }); + + it("describes which desktop bootstrap endpoint is missing", () => { + vi.stubGlobal("window", { + location: new URL("http://127.0.0.1:5733/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isDesktopEnvironmentBootstrapIncompleteError(error)).toBe(true); + if (!isDesktopEnvironmentBootstrapIncompleteError(error)) { + throw new Error("Expected a structured desktop bootstrap error."); + } + expect(error).toMatchObject({ + hasHttpBaseUrl: true, + hasWsBaseUrl: false, + message: "Desktop bootstrap is missing wsBaseUrl for the local environment.", + }); + }); + + it("preserves an unsupported window-origin protocol", () => { + vi.stubGlobal("window", { + location: { origin: "file:///tmp/t3code/" }, + history: { replaceState: vi.fn() }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentProtocolUnsupportedError(error)).toBe(true); + if (!isPrimaryEnvironmentProtocolUnsupportedError(error)) { + throw new Error("Expected a structured primary environment protocol error."); + } + expect(error).toMatchObject({ + source: "window-origin", + protocol: "file:", + message: "The window-origin primary environment target uses unsupported protocol file:.", + }); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 3cb570d66ab..e888560539d 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -38,7 +38,14 @@ export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionSta export { PrimaryEnvironmentHttpClient } from "./httpClient"; export { + DesktopEnvironmentBootstrapIncompleteError, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + PrimaryEnvironmentProtocolUnsupportedError, + PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, + type PrimaryEnvironmentTarget, } from "./target"; diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index c62907d3572..a14a99ec4dc 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,7 +1,72 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const PrimaryEnvironmentTargetSource = Schema.Literals([ + "configured", + "window-origin", + "desktop-managed", +]); +type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type; + +const PrimaryEnvironmentUrlKind = Schema.Literals([ + "http-base-url", + "websocket-base-url", + "development-server-url", + "window-location-url", +]); +type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type; + +export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentUrlInvalidError", + { + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`; + } +} + +export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentProtocolUnsupportedError", + { + source: PrimaryEnvironmentTargetSource, + protocol: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`; + } +} + +export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErrorClass()( + "DesktopEnvironmentBootstrapIncompleteError", + { + hasHttpBaseUrl: Schema.Boolean, + hasWsBaseUrl: Schema.Boolean, + }, +) { + override get message(): string { + const missing = [ + ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]), + ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]), + ]; + return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`; + } +} + +export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); +export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( + PrimaryEnvironmentProtocolUnsupportedError, +); +export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is( + DesktopEnvironmentBootstrapIncompleteError, +); export interface PrimaryEnvironmentTarget { - readonly source: "configured" | "window-origin" | "desktop-managed"; + readonly source: PrimaryEnvironmentTargetSource; readonly target: { readonly httpBaseUrl: string; readonly wsBaseUrl: string; @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; } -function normalizeBaseUrl(rawValue: string): string { - return new URL(rawValue, window.location.origin).toString(); +function parseTargetUrl(input: { + readonly rawValue: string; + readonly baseUrl?: string; + readonly source: PrimaryEnvironmentTargetSource; + readonly urlKind: PrimaryEnvironmentUrlKind; +}): URL { + try { + return input.baseUrl === undefined + ? new URL(input.rawValue) + : new URL(input.rawValue, input.baseUrl); + } catch (cause) { + throw new PrimaryEnvironmentUrlInvalidError({ + source: input.source, + urlKind: input.urlKind, + cause, + }); + } +} + +function normalizeBaseUrl( + rawValue: string, + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, +): string { + return parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source, + urlKind, + }).toString(); } function swapBaseUrlProtocol( rawValue: string, nextProtocol: "http:" | "https:" | "ws:" | "wss:", + urlKind: PrimaryEnvironmentUrlKind, ): string { - const url = new URL(normalizeBaseUrl(rawValue)); + const url = parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source: "configured", + urlKind, + }); url.protocol = nextProtocol; return url.toString(); } @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } -function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { +function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { + const httpBaseUrl = primaryTarget.target.httpBaseUrl; const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); if (!configuredDevServerUrl) { return httpBaseUrl; } - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(httpBaseUrl); - const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); + const currentUrl = parseTargetUrl({ + rawValue: window.location.href, + source: "window-origin", + urlKind: "window-location-url", + }); + const targetUrl = parseTargetUrl({ + rawValue: httpBaseUrl, + source: primaryTarget.source, + urlKind: "http-base-url", + }); + const devServerUrl = parseTargetUrl({ + rawValue: configuredDevServerUrl, + baseUrl: currentUrl.origin, + source: "configured", + urlKind: "development-server-url", + }); const isCurrentOriginDevServer = (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { const resolvedHttpBaseUrl = configuredHttpBaseUrl ?? (configuredWsBaseUrl?.startsWith("wss:") - ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") - : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); + ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") + : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); const resolvedWsBaseUrl = configuredWsBaseUrl ?? (configuredHttpBaseUrl?.startsWith("https:") - ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") - : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); + ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") + : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), - wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), }, }; } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = normalizeBaseUrl(window.location.origin); - const url = new URL(httpBaseUrl); + const url = parseTargetUrl({ + rawValue: window.location.origin, + source: "window-origin", + urlKind: "http-base-url", + }); + const httpBaseUrl = url.toString(); if (url.protocol === "http:") { url.protocol = "ws:"; } else if (url.protocol === "https:") { url.protocol = "wss:"; } else { - throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); + throw new PrimaryEnvironmentProtocolUnsupportedError({ + source: "window-origin", + protocol: url.protocol, + }); } return { source: "window-origin", @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { return null; } if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { - throw new Error( - "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", - ); + throw new DesktopEnvironmentBootstrapIncompleteError({ + hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), + hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), + }); } return { source: "desktop-managed", target: { - httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), - wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), + httpBaseUrl: normalizeBaseUrl( + desktopBootstrap.httpBaseUrl, + "desktop-managed", + "http-base-url", + ), + wsBaseUrl: normalizeBaseUrl( + desktopBootstrap.wsBaseUrl, + "desktop-managed", + "websocket-base-url", + ), }, }; } @@ -139,11 +268,12 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); - if (!primaryTarget) { - throw new Error("Unable to resolve the primary environment HTTP base URL."); - } - const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); + const url = parseTargetUrl({ + rawValue: resolveHttpRequestBaseUrl(primaryTarget), + source: primaryTarget.source, + urlKind: "http-base-url", + }); url.pathname = pathname; if (searchParams) { url.search = new URLSearchParams(searchParams).toString(); @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ?? From d53237cbd14cbdc7fe6b1f1ec2f8aa224a3f7aff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:50:14 -0700 Subject: [PATCH 138/142] [codex] sanitize ACP native event diagnostics (#3417) Co-authored-by: codex --- .../provider/Layers/EventNdjsonLogger.test.ts | 36 +++++ .../src/provider/Layers/EventNdjsonLogger.ts | 17 ++- .../src/provider/acp/AcpNativeLogging.test.ts | 137 ++++++++++++++++++ .../src/provider/acp/AcpNativeLogging.ts | 71 +++++++-- packages/shared/src/observability.test.ts | 9 ++ packages/shared/src/observability.ts | 29 +++- 6 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpNativeLogging.test.ts diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index f2c317a9127..71ac7831ed4 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -6,9 +6,13 @@ 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,6 +33,38 @@ 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 = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index c934abbfe3d..8c20a4c1936 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -11,6 +11,7 @@ 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), }); } }), @@ -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; } 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 5814d935197..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"; @@ -8,13 +9,58 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +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), }; } @@ -47,12 +93,15 @@ 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, + }), ), ); @@ -70,7 +119,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => writeNativeAcpLog({ kind: "protocol", - payload: event, + payload: formatProtocolLogPayload(event), }), } satisfies NonNullable, } diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 57537b63e19..f9cf1b1cbcf 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -15,11 +15,20 @@ import * as Tracer from "effect/Tracer"; import { causeErrorTag, compactTraceAttributes, + errorTag, makeLocalFileTracer, makeTraceSink, type TraceRecord, } from "./observability.ts"; +describe("errorTag", () => { + it("reports structural tags without retaining arbitrary values", () => { + assert.equal(errorTag({ _tag: "AcpRequestError" }), "AcpRequestError"); + assert.equal(errorTag(new TypeError("secret-token-value")), "TypeError"); + assert.equal(errorTag({ _tag: "secret token value" }), "TaggedError"); + }); +}); + describe("causeErrorTag", () => { it("reports the tagged failure value instead of the Cause reason wrapper", () => { assert.equal( diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 68d4985db95..1b92b98739d 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -73,18 +73,33 @@ export interface OtlpTraceRecord extends BaseTraceRecord { export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; -function taggedErrorName(error: unknown): string { - return typeof error === "object" && error !== null && "_tag" in error - ? String(error._tag) - : error instanceof Error - ? error.name - : typeof error; +function isStructuralTag(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 128 && + /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) + ); +} + +export function errorTag(error: unknown): string { + try { + if (typeof error === "object" && error !== null && "_tag" in error) { + return isStructuralTag(error._tag) ? error._tag : "TaggedError"; + } + if (error instanceof Error) { + return isStructuralTag(error.name) ? error.name : "Error"; + } + } catch { + return "UnknownError"; + } + return typeof error; } export function causeErrorTag(cause: Cause.Cause): string { const failure = Cause.findErrorOption(cause); if (Option.isSome(failure)) { - return taggedErrorName(failure.value); + return errorTag(failure.value); } return cause.reasons[0]?._tag ?? "Empty"; } From 0cd0ed1a3dc3280cf0be6128b377abc095ba806f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:50:46 -0700 Subject: [PATCH 139/142] [codex] Sanitize client error log diagnostics (#3405) Co-authored-by: codex --- .../src/state/use-thread-composer-state.ts | 8 +- apps/web/src/components/DiffPanel.tsx | 12 +- apps/web/src/components/Sidebar.tsx | 5 +- .../components/settings/SettingsPanels.tsx | 7 +- apps/web/src/hooks/useSettings.ts | 11 +- apps/web/src/observability/clientTracing.ts | 18 ++- .../src/connection/supervisor.ts | 5 +- packages/client-runtime/src/errors/index.ts | 1 + .../client-runtime/src/errors/safeLog.test.ts | 55 +++++++++ packages/client-runtime/src/errors/safeLog.ts | 107 ++++++++++++++++++ .../src/state/archivedThreads.test.ts | 27 ++++- .../src/state/archivedThreads.ts | 7 +- packages/client-runtime/src/state/session.ts | 8 +- packages/client-runtime/src/state/shell.ts | 29 +++-- 14 files changed, 260 insertions(+), 40 deletions(-) create mode 100644 packages/client-runtime/src/errors/safeLog.test.ts create mode 100644 packages/client-runtime/src/errors/safeLog.ts diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d7b66751d04..60970b32a4d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -231,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/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ae356fe08ef..f39af581d5a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -4,6 +4,7 @@ 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, @@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff void (async () => { const result = await openInPreferredEditor(targetPath); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to open diff file in editor.", squashAtomCommandFailure(result)); + console.warn("Failed to open diff file in editor.", { + operation: "open-diff-file", + ...(routeThreadRef + ? { + environmentId: routeThreadRef.environmentId, + threadId: routeThreadRef.threadId, + } + : {}), + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); } })(); }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f6eb8602cf8..f3ed88bd3b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -55,6 +55,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ @@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6e08fa68ef1..994cbb08f23 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,6 +12,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() { refreshingRef.current = false; setIsRefreshingProviders(false); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", squashAtomCommandFailure(result)); + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId: primaryEnvironment.environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); } })(); }, [primaryEnvironment, refreshServerProviders]); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bf8b3a7dd08..514484d896a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -23,6 +23,7 @@ import { DEFAULT_CLIENT_SETTINGS, type UnifiedSettings, } from "@t3tools/contracts/settings"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { + operation: "hydrate", + ...safeErrorLogAttributes(error), + }); } finally { if (hydrationGeneration === clientSettingsHydrationGeneration) { setClientSettingsHydrated(true); @@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void { void ensureLocalApi() .persistence.setClientSettings(settings) .catch((error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); }); } diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 11045392bae..27d348019e2 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; @@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise 0) { - return error.message; - } - - return String(error); -} - export async function __resetClientTracingForTests() { configurationGeneration++; activeConfigKey = null; diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 99889916a9a..d9efcd4263a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -26,6 +26,7 @@ import { type SupervisorConnectionState, } from "./model.ts"; import * as RpcSession from "../rpc/session.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; @@ -503,11 +504,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( !establishment.exit.cause.reasons.some(Cause.isFailReason); const outcome = failureFromExit(target, establishment.exit, false, false); if (isUnexpectedDefect) { + const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( Effect.annotateLogs({ "environment.id": target.environmentId, "environment.label": target.label, - cause: Cause.pretty(establishment.exit.cause), + "cause.reason_count": establishment.exit.cause.reasons.length, + ...safeErrorLogAttributes(defect), }), ); } diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts index a29060e6758..7eb6244e5a7 100644 --- a/packages/client-runtime/src/errors/index.ts +++ b/packages/client-runtime/src/errors/index.ts @@ -1,2 +1,3 @@ export * from "./errorTrace.ts"; +export * from "./safeLog.ts"; export * from "./transport.ts"; diff --git a/packages/client-runtime/src/errors/safeLog.test.ts b/packages/client-runtime/src/errors/safeLog.test.ts new file mode 100644 index 00000000000..b8dfb235ffe --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { safeErrorLogAttributes } from "./safeLog.ts"; + +describe("safeErrorLogAttributes", () => { + it("keeps correlation and stack frames without serializing messages or nested causes", () => { + const cause = Object.assign(new Error("nested-cause-secret-sentinel"), { + traceId: "trace-safe-123", + }); + const error = Object.assign(new Error("outer-error-secret-sentinel", { cause }), { + _tag: "ProjectRemovalError", + }); + error.stack = [ + "ProjectRemovalError: outer-error-secret-sentinel", + " at removeProject (https://user:password@example.com/project.ts?token=secret#fragment)", + ].join("\n"); + + const attributes = safeErrorLogAttributes(error); + + expect(attributes).toMatchObject({ + errorType: "error", + errorName: "Error", + errorTag: "ProjectRemovalError", + traceId: "trace-safe-123", + stack: " at removeProject (https://example.com/project.ts)", + }); + const diagnosticText = Object.values(attributes).map(String).join("\n"); + expect(diagnosticText).not.toContain("outer-error-secret-sentinel"); + expect(diagnosticText).not.toContain("nested-cause-secret-sentinel"); + expect(diagnosticText).not.toContain("user:password"); + expect(diagnosticText).not.toContain("token=secret"); + }); + + it("does not trust arbitrary object messages or tags", () => { + const attributes = safeErrorLogAttributes({ + _tag: "payload-secret-sentinel", + message: "message-secret-sentinel", + cause: { traceId: "trace id with unsafe whitespace" }, + }); + + expect(attributes).toEqual({ errorType: "object" }); + }); + + it("skips an unsafe outer trace id when a nested safe trace id is available", () => { + const attributes = safeErrorLogAttributes({ + traceId: "unsafe trace id", + cause: { traceId: "trace-safe-inner" }, + }); + + expect(attributes).toEqual({ + errorType: "object", + traceId: "trace-safe-inner", + }); + }); +}); diff --git a/packages/client-runtime/src/errors/safeLog.ts b/packages/client-runtime/src/errors/safeLog.ts new file mode 100644 index 00000000000..60730d4d35c --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.ts @@ -0,0 +1,107 @@ +const SAFE_ERROR_LABEL = + /^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/; +const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const STACK_FRAME_LIMIT = 32; + +export interface SafeErrorLogAttributes { + readonly errorType: "error" | "array" | "null" | "object" | "primitive"; + readonly errorName?: string; + readonly errorTag?: string; + readonly traceId?: string; + readonly stack?: string; +} + +function readSafeLabel(value: unknown): string | undefined { + return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined; +} + +function sanitizeStackUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value; + } +} + +function sanitizeStackFrame(frame: string): string { + return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl); +} + +function readSafeStack(error: Error): string | undefined { + try { + const frames = error.stack + ?.split(/\r?\n/) + .filter((line) => /^\s*at\s+/.test(line) || /^[^@\s]+@(?:https?|file):\/\//.test(line)) + .slice(0, STACK_FRAME_LIMIT) + .map(sanitizeStackFrame); + return frames && frames.length > 0 ? frames.join("\n") : undefined; + } catch { + return undefined; + } +} + +function readErrorTag(error: unknown): string | undefined { + try { + if (typeof error !== "object" || error === null) { + return undefined; + } + return readSafeLabel((error as { readonly _tag?: unknown })._tag); + } catch { + return undefined; + } +} + +function readTraceId(error: unknown): string | undefined { + try { + const seen = new Set(); + let current: unknown = error; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const record = current as { readonly cause?: unknown; readonly traceId?: unknown }; + if (typeof record.traceId === "string" && SAFE_TRACE_ID.test(record.traceId)) { + return record.traceId; + } + current = record.cause; + } + + return undefined; + } catch { + return undefined; + } +} + +export function safeErrorLogAttributes(error: unknown): SafeErrorLogAttributes { + const errorTag = readErrorTag(error); + const traceId = readTraceId(error); + + if (error instanceof Error) { + const errorName = readSafeLabel(error.name); + const stack = readSafeStack(error); + return { + errorType: "error", + ...(errorName !== undefined ? { errorName } : {}), + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + ...(stack !== undefined ? { stack } : {}), + }; + } + + return { + errorType: + error === null + ? "null" + : Array.isArray(error) + ? "array" + : typeof error === "object" + ? "object" + : "primitive", + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + }; +} diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts index 29679d00ffe..aa16b9cadcd 100644 --- a/packages/client-runtime/src/state/archivedThreads.test.ts +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -1,7 +1,10 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { expect, it } from "vite-plus/test"; import { + createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, parseArchivedThreadsEnvironmentKey, } from "./archivedThreads.ts"; @@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => { expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); }); + +it("does not expose an archived snapshot failure message", () => { + const environmentId = EnvironmentId.make("env-sensitive"); + const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: () => + Atom.make( + AsyncResult.failure( + Cause.fail(new Error("credential=secret-value")), + ), + ), + labelPrefix: "test:archived-thread-snapshots", + }); + const registry = AtomRegistry.make(); + + expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({ + snapshots: [], + error: "Failed to load archived threads.", + isLoading: false, + }); + + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 7441d46cf32..8c64f1ae506 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,6 +1,5 @@ import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; import * as Arr from "effect/Array"; -import * as Cause from "effect/Cause"; import { pipe } from "effect/Function"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -60,11 +59,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { } if (error === null && result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = - cause instanceof Error && cause.message.trim().length > 0 - ? cause.message - : "Failed to load archived threads."; + error = "Failed to load archived threads."; } } diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 84e9dbdd8eb..3cb62009a20 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -8,6 +8,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -16,9 +17,10 @@ export function initialConfigOption( return initialConfig.pipe( Effect.map(Option.some), Effect.catch((error) => - Effect.logWarning("Could not load the initial environment configuration.", { - error, - }).pipe(Effect.as(Option.none())), + Effect.logWarning("Could not load the initial environment configuration.").pipe( + Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), + Effect.as(Option.none()), + ), ), ); } diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 428e99b76d0..2b0ba6346f5 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; @@ -42,11 +43,7 @@ function shellStatusForSnapshot( return Option.isSome(snapshot) ? "cached" : "empty"; } -function formatShellError(error: unknown): string { - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "Could not synchronize environment data."; -} +const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -57,7 +54,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.logWarning("Could not load cached environment shell.").pipe( Effect.annotateLogs({ environmentId, - error: error.message, + ...safeErrorLogAttributes(error), }), Effect.as(Option.none()), ), @@ -78,7 +75,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.logWarning("Could not persist environment shell cache.").pipe( Effect.annotateLogs({ environmentId, - error: error.message, + ...safeErrorLogAttributes(error), }), ), ), @@ -110,11 +107,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }, ); const setStreamError = (error: unknown) => - SubscriptionRef.update(state, (current) => ({ - ...current, - status: shellStatusForSnapshot(current.snapshot), - error: Option.some(formatShellError(error)), - })); + Effect.logWarning("Could not synchronize the environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE), + })), + ), + ); const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem, From 46fdc769d90fc46efd69f4954765e64030353199 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:50:58 -0700 Subject: [PATCH 140/142] [codex] Structure telemetry identity errors (#3306) Co-authored-by: codex --- apps/server/src/telemetry/Identify.test.ts | 172 +++++++++++++ apps/server/src/telemetry/Identify.ts | 276 +++++++++++++++++---- 2 files changed, 405 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/telemetry/Identify.test.ts 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 f7458bcd8c8..b6c3d0066df 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -3,7 +3,9 @@ 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 * as ServerConfig from "../config.ts"; @@ -18,66 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({ userID: Schema.String, }); -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - operation: Schema.Literal("hash_identifier"), - algorithm: Schema.Literal("SHA-256"), - cause: Schema.Defect(), -}) { +export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); +export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; + +export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( + "TelemetryIdentityReadError", + { + source: TelemetryIdentitySource, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( + "TelemetryIdentityDecodeError", + { + source: Schema.Literals(["codex", "claude"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { override get message(): string { - return `Failed to hash telemetry identifier with ${this.algorithm}.`; + return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; } } -const hash = (value: string) => +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({ - operation: "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.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 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, + }), ), ); @@ -90,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()), +); From 1cbe8a7a90cb7b942ede116e744a71d45da8fdb0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:51:52 -0700 Subject: [PATCH 141/142] [codex] Structure VCS project config failures (#3315) Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 115 ++++++++++++++++++- apps/server/src/vcs/VcsProjectConfig.ts | 99 +++++++++++----- 2 files changed, 182 insertions(+), 32 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index b4977173bdf..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* () { @@ -69,8 +126,13 @@ describe("VcsProjectConfig", () => { }); it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { + 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({ @@ -84,8 +146,53 @@ describe("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) => { diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index c3590f5dbb0..bd8f4515007 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -18,7 +18,7 @@ const ProjectVcsConfig = Schema.Struct({ vcsKind: Schema.optional(VcsDriverKind), }); const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); -const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); +const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson); type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; @@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput { readonly requestedKind?: VcsDriverKindType | "auto"; } +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< VcsProjectConfig, { @@ -40,8 +54,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -const parseConfig = (raw: string): Option.Option => - decodeProjectVcsConfigJson(raw); +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.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -51,7 +71,21 @@ export const make = Effect.gen(function* () { let current = cwd; while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); - if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + 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); } @@ -64,30 +98,32 @@ export const make = Effect.gen(function* () { }); const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + cwd: string, configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.logWarning("failed to read VCS project config", { - configPath, - error, - }).pipe(Effect.as(Option.none())), + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "read", + cwd, + configPath, + cause, + }), ), ); - if (Option.isNone(raw)) { - return "auto" as const; - } - - const parsed = parseConfig(raw.value); - if (Option.isNone(parsed)) { - yield* Effect.logWarning("invalid VCS project config", { - configPath, - }); - return "auto" as const; - } - - return configuredKind(parsed.value); + const parsed = yield* decodeProjectVcsConfigJson(raw).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "decode", + cwd, + configPath, + cause, + }), + ), + ); + return configuredKind(parsed); }); const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( @@ -97,11 +133,18 @@ export const make = Effect.gen(function* () { return input.requestedKind; } - const configPath = yield* findConfigPath(input.cwd); - return yield* Option.match(configPath, { - onNone: () => Effect.succeed("auto" as const), - onSome: readConfiguredKind, - }); + 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({ From ff0f702c7cb5006acf72b63edf5c0df7686f60c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:52:20 -0700 Subject: [PATCH 142/142] [codex] Structure client VCS action errors (#3263) Co-authored-by: codex --- apps/web/src/state/sourceControlActions.ts | 56 ++++-- .../src/state/vcsAction.test.ts | 141 +++++++++++++++ .../client-runtime/src/state/vcsAction.ts | 163 +++++++++++++----- 3 files changed, 306 insertions(+), 54 deletions(-) diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 3f532739f25..297ae5717df 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -126,12 +126,6 @@ function resolveScope(scope: SourceControlActionScope) { }; } -function unavailableResult(message: string) { - return AsyncResult.failure( - Cause.fail(new VcsActionUnavailableError({ message })), - ); -} - export function useSourceControlActionRunning( scope: SourceControlActionScope, kinds: ReadonlyArray, @@ -149,7 +143,15 @@ export function useVcsInitAction(scope: SourceControlActionScope) { const action = useCallback(async () => { const target = resolveScope(scope); if (target === null) { - return unavailableResult("Git init is unavailable."); + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "init", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); } return init({ environmentId: target.environmentId, @@ -172,7 +174,15 @@ export function useVcsPullAction(scope: SourceControlActionScope) { const action = useCallback(async () => { const target = resolveScope(scope); if (target === null) { - return unavailableResult("Git pull is unavailable."); + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "pull", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); } return pull({ environmentId: target.environmentId, @@ -211,7 +221,15 @@ export function useGitStackedAction(scope: SourceControlActionScope) { onProgress?: (event: GitActionProgressEvent) => void; }) => { if (resolveScope(scope) === null) { - return unavailableResult("Git action is unavailable."); + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "run_change_request", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); } return runStackedAction({ actionId: input.actionId, @@ -257,7 +275,15 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi }) => { const target = resolveScope(scope); if (target === null) { - return unavailableResult("Repository publishing is unavailable."); + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "publish_repository", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); } return publishRepository({ environmentId: target.environmentId, @@ -286,7 +312,15 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { const target = resolveScope(scope); if (target === null) { - return unavailableResult("Pull request thread preparation is unavailable."); + return AsyncResult.failure( + Cause.fail( + new VcsActionUnavailableError({ + operation: "prepare_pull_request_thread", + environmentId: scope.environmentId, + cwd: scope.cwd, + }), + ), + ); } return preparePullRequestThread({ environmentId: target.environmentId, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b2ac9507319..7e618535ad8 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; @@ -21,12 +22,18 @@ import { EMPTY_VCS_ACTION_STATE, getVcsActionTargetKey, normalizeVcsActionProgressEvent, + parseVcsActionTargetKey, + VcsActionMissingTerminalEventError, + VcsActionRemoteFailureError, + VcsActionTargetKeyParseError, + VcsActionUnavailableError, } from "./vcsAction.ts"; const actionId = "action-123"; const action = "commit_push" as const; const cwd = "/repo"; const environmentId = EnvironmentId.make("environment-1"); +const isVcsActionUnavailableError = Schema.is(VcsActionUnavailableError); const result: GitRunStackedActionResult = { action, branch: { @@ -57,6 +64,28 @@ function progress(event: T): T { } describe("vcsActionState", () => { + it("preserves malformed target key diagnostics and the native cause without copying the key", () => { + const key = "not-json-with-credential=do-not-log"; + let error: unknown; + + try { + parseVcsActionTargetKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(VcsActionTargetKeyParseError); + expect(error).toMatchObject({ keyLength: key.length, cause: expect.any(SyntaxError) }); + expect(error).not.toHaveProperty("key"); + expect((error as Error).message).not.toContain(key); + }); + + it("rejects invalid target key shapes", () => { + const key = JSON.stringify([environmentId]); + + expect(() => parseVcsActionTargetKey(key)).toThrowError(VcsActionTargetKeyParseError); + }); + it("projects phase and hook progress without owning the async operation", () => { const initial = beginVcsActionState({ operation: "run_change_request", @@ -254,6 +283,7 @@ describe("vcsActionState", () => { target, transportActionId, actionId, + action, onProgress: (event) => Effect.sync(() => { observed.push(event); @@ -266,6 +296,85 @@ describe("vcsActionState", () => { }), ); + it.effect("retains structural remote failure context without copying the remote payload", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const remoteMessage = "The remote rejected the push with credential=do-not-log."; + const error = yield* consumeVcsActionProgress( + Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "action_failed", + phase: "push", + message: remoteMessage, + }, + ]), + { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionRemoteFailureError); + expect(error).toMatchObject({ + actionId, + transportActionId, + action, + environmentId, + cwd, + phase: "push", + remoteMessageLength: remoteMessage.length, + }); + expect(error).not.toHaveProperty("detail"); + expect(error.message).toBe("Source control action 'commit_push' failed during push."); + expect(error.message).not.toContain(remoteMessage); + }), + ); + + it.effect("reports a missing terminal event as a protocol failure", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const error = yield* consumeVcsActionProgress( + Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Committing...", + }, + ]), + { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }, + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionMissingTerminalEventError); + expect(error).toMatchObject({ + actionId, + transportActionId, + action, + environmentId, + cwd, + }); + expect(error.message).toBe( + "Source control action 'commit_push' ended without a terminal result.", + ); + }), + ); + it("keys mutation ownership by environment and cwd", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry, @@ -286,6 +395,38 @@ describe("vcsActionState", () => { registry.dispose(); }); + it("retains the incomplete target and operation when tracking is unavailable", async () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const manager = createVcsActionManager(runtime); + const registry = AtomRegistry.make(); + const result = await manager.track( + registry, + { environmentId, cwd: null }, + { operation: "pull", label: "Pulling latest changes" }, + async () => AsyncResult.success(undefined), + ); + + expect(AsyncResult.isFailure(result)).toBe(true); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(VcsActionUnavailableError); + if (!isVcsActionUnavailableError(error)) { + throw error; + } + expect(error).toMatchObject({ + operation: "pull", + environmentId, + cwd: null, + }); + expect(error.message).toBe("Source control operation 'pull' is unavailable."); + } + + registry.dispose(); + }); + it("tracks finite mutations without letting an older completion clear newer state", async () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry, diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index b06f5ac65bc..8ae3219a243 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -1,10 +1,11 @@ import { EnvironmentId, type EnvironmentId as EnvironmentIdType, + GitActionProgressPhase, type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, - type GitStackedAction, + GitStackedAction, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -24,16 +25,18 @@ import { } from "./runtime.ts"; import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; -export type VcsActionOperation = - | "refresh_status" - | "run_change_request" - | "pull" - | "switch_ref" - | "create_ref" - | "create_worktree" - | "init" - | "publish_repository" - | "prepare_pull_request_thread"; +export const VcsActionOperation = Schema.Literals([ + "refresh_status", + "run_change_request", + "pull", + "switch_ref", + "create_ref", + "create_worktree", + "init", + "publish_repository", + "prepare_pull_request_thread", +]); +export type VcsActionOperation = typeof VcsActionOperation.Type; export interface VcsActionState { readonly isRunning: boolean; @@ -77,16 +80,66 @@ export interface RunVcsStackedActionInput { export class VcsActionUnavailableError extends Schema.TaggedErrorClass()( "VcsActionUnavailableError", { - message: Schema.String, + operation: VcsActionOperation, + environmentId: Schema.NullOr(EnvironmentId), + cwd: Schema.NullOr(Schema.String), }, -) {} +) { + override get message(): string { + return `Source control operation '${this.operation.replaceAll("_", " ")}' is unavailable.`; + } +} + +export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass()( + "VcsActionRemoteFailureError", + { + actionId: Schema.String, + transportActionId: Schema.String, + action: GitStackedAction, + environmentId: EnvironmentId, + cwd: Schema.String, + phase: Schema.NullOr(GitActionProgressPhase), + remoteMessageLength: Schema.Number, + }, +) { + override get message(): string { + const phase = this.phase === null ? "execution" : this.phase; + return `Source control action '${this.action}' failed during ${phase}.`; + } +} -export class VcsActionExecutionError extends Schema.TaggedErrorClass()( - "VcsActionExecutionError", +export class VcsActionMissingTerminalEventError extends Schema.TaggedErrorClass()( + "VcsActionMissingTerminalEventError", { - message: Schema.String, + actionId: Schema.String, + transportActionId: Schema.String, + action: GitStackedAction, + environmentId: EnvironmentId, + cwd: Schema.String, }, -) {} +) { + override get message(): string { + return `Source control action '${this.action}' ended without a terminal result.`; + } +} + +export class VcsActionTargetKeyParseError extends Schema.TaggedErrorClass()( + "VcsActionTargetKeyParseError", + { + keyLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid source control action target key (${this.keyLength} characters).`; + } +} + +export const VcsActionExecutionError = Schema.Union([ + VcsActionRemoteFailureError, + VcsActionMissingTerminalEventError, +]); +export type VcsActionExecutionError = typeof VcsActionExecutionError.Type; export const EMPTY_VCS_ACTION_STATE = Object.freeze({ isRunning: false, @@ -104,6 +157,9 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze({ const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); let nextLocalActionId = 0; +const decodeVcsActionTargetKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String]), +); export const vcsActionStateAtom = Atom.family((key: string) => { return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( @@ -124,12 +180,13 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null { return JSON.stringify([target.environmentId, target.cwd]); } -function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { - const [environmentId, cwd] = JSON.parse(key) as [string, string]; - return { - environmentId: EnvironmentId.make(environmentId), - cwd, - }; +export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { + try { + const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key)); + return { environmentId, cwd }; + } catch (cause) { + throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause }); + } } export function getVcsActionStateAtom(target: VcsActionTarget) { @@ -200,6 +257,7 @@ export function consumeVcsActionProgress( readonly target: ResolvedVcsActionTarget; readonly transportActionId: string; readonly actionId: string; + readonly action: GitStackedAction; readonly onProgress: (event: GitActionProgressEvent) => Effect.Effect; }, ): Effect.Effect { @@ -226,15 +284,25 @@ export function consumeVcsActionProgress( return Effect.succeed(terminalEvent.result); } if (terminalEvent?.kind === "action_failed") { - return Effect.fail( - new VcsActionExecutionError({ - message: terminalEvent.message, + return Effect.fail( + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: terminalEvent.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + phase: terminalEvent.phase, + remoteMessageLength: terminalEvent.message.length, }), ); } - return Effect.fail( - new VcsActionExecutionError({ - message: "Source control action ended without a result.", + return Effect.fail( + new VcsActionMissingTerminalEventError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: input.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, }), ); }), @@ -339,18 +407,25 @@ export function applyVcsActionProgressEvent( export function createVcsActionManager( runtime: Atom.AtomRuntime, ) { - const unavailableTargetKey = "vcs-action-target:unavailable"; const runStackedActionCommands = new Map< string, AtomCommand >(); - const getRunStackedActionCommand = (key: string) => { - const existing = runStackedActionCommands.get(key); + const getRunStackedActionCommand = (requestedTarget: VcsActionTarget) => { + const targetKey = getVcsActionTargetKey(requestedTarget); + const commandKey = + targetKey ?? + JSON.stringify([ + "vcs-action-target:unavailable", + requestedTarget.environmentId, + requestedTarget.cwd, + ]); + const existing = runStackedActionCommands.get(commandKey); if (existing !== undefined) { return existing; } - const target = key === unavailableTargetKey ? null : parseVcsActionTargetKey(key); - const stateAtom = target === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); + const target = targetKey === null ? null : parseVcsActionTargetKey(targetKey); + const stateAtom = targetKey === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(targetKey); const command = createRuntimeCommand< EnvironmentRegistry | R, E, @@ -358,14 +433,16 @@ export function createVcsActionManager( GitRunStackedActionResult, unknown >(runtime, { - label: `vcs-action:run-stacked:${key}`, + label: `vcs-action:run-stacked:${commandKey}`, scheduler: vcsCommandScheduler, - concurrency: { mode: "serial", key: () => key }, + concurrency: { mode: "serial", key: () => commandKey }, execute: (input: RunVcsStackedActionInput, registry) => { if (target === null) { return Effect.fail( new VcsActionUnavailableError({ - message: "Source control action is unavailable.", + operation: "run_change_request", + environmentId: requestedTarget.environmentId, + cwd: requestedTarget.cwd, }), ); } @@ -396,6 +473,7 @@ export function createVcsActionManager( target, transportActionId, actionId: input.actionId, + action: input.action, onProgress: (event) => Effect.sync(() => { const current = registry.get(stateAtom); @@ -427,7 +505,7 @@ export function createVcsActionManager( ); }, }); - runStackedActionCommands.set(key, command); + runStackedActionCommands.set(commandKey, command); return command; }; @@ -446,10 +524,7 @@ export function createVcsActionManager( return { stateAtom: getVcsActionStateAtom, - runStackedAction: (target: VcsActionTarget) => { - const key = getVcsActionTargetKey(target); - return getRunStackedActionCommand(key ?? unavailableTargetKey); - }, + runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target), track: async ( registry: AtomRegistry.AtomRegistry, target: VcsActionTarget, @@ -461,7 +536,9 @@ export function createVcsActionManager( return AsyncResult.failure( Cause.fail( new VcsActionUnavailableError({ - message: "Source control action is unavailable.", + operation: input.operation, + environmentId: target.environmentId, + cwd: target.cwd, }), ), );